experiments of a tiny cytube-like player with yt-dlp
0

Configure Feed

Select the types of activity you want to include in your feed.

poc

karitham (May 17, 2026, 3:53 AM +0200) 78053575

+5604
+8
.gitignore
··· 1 + /target 2 + .direnv 3 + result* 4 + .env 5 + *.swp 6 + *.swo 7 + *.lock 8 + pre-commit.sh
+51
Cargo.toml
··· 1 + [package] 2 + name = "moqbox" 3 + version = "0.1.0" 4 + edition = "2021" 5 + description = "Ultra-low latency shared media rooms over MoQ" 6 + license = "MIT OR Apache-2.0" 7 + 8 + [dependencies] 9 + # ── Async ────────────────────────────────────────────────────────── 10 + tokio = { version = "1", features = ["full"] } 11 + anyhow = "1" 12 + thiserror = "2" 13 + 14 + # ── HTTP / SSR ───────────────────────────────────────────────────── 15 + axum = { version = "0.8", features = ["ws"] } 16 + tower = "0.5" 17 + tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-gzip"] } 18 + tracing = "0.1" 19 + tracing-subscriber = { version = "0.3", features = ["env-filter"] } 20 + serde = { version = "1", features = ["derive"] } 21 + serde_json = "1" 22 + 23 + # ── Templates ────────────────────────────────────────────────────── 24 + askama = "0.13" 25 + 26 + # ── CLI ──────────────────────────────────────────────────────────── 27 + clap = { version = "4", features = ["derive", "env"] } 28 + 29 + # ── WebSocket / MoQ Transport ─────────────────────────────────────── 30 + tokio-tungstenite = "0.24" 31 + futures-util = "0.3" 32 + 33 + # ── TLS (reserved for future use) ───────────────────────────────────── 34 + rustls = { version = "0.23", features = ["ring"] } 35 + rcgen = "0.13" 36 + 37 + # ── Identifiers ──────────────────────────────────────────────────── 38 + uuid = { version = "1", features = ["v4", "serde"] } 39 + 40 + # ── Database ──────────────────────────────────────────────────────── 41 + sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] } 42 + chrono = { version = "0.4", features = ["serde"] } 43 + bytes = "1" 44 + 45 + [dev-dependencies] 46 + reqwest = { version = "0.12", features = ["json"] } 47 + 48 + [profile.release] 49 + lto = "fat" 50 + codegen-units = 1 51 + strip = true
+22
README.md
··· 1 + # moqbox 2 + 3 + Shared media rooms. Paste a URL, everyone watches together. 4 + Requires `ffmpeg` and `yt-dlp` on PATH. Run with `cargo run`, open `localhost:3000`. 5 + 6 + ## Architecture 7 + 8 + One Axum binary serving SSR pages, REST API, and a custom media streaming 9 + protocol over WebSocket on the same port. Per-room mpsc actor processes commands 10 + (queue, skip, chat, register) sequentially — no locks. Room state persisted to 11 + SQLite (WAL mode). 12 + 13 + yt-dlp resolves URLs to direct stream addresses. FFmpeg transcodes to fragmented 14 + MP4 at real-time speed (`-re`). fMP4 boxes are read from stdout as moof+mdat 15 + pairs and published over per-room broadcast channels. The video init segment 16 + (ftyp+moov) is cached separately. Forward tasks send cached init first, then 17 + catch up to the broadcast live edge before forwarding, so late joiners start at 18 + the live position. 19 + 20 + Clients use MSE with a single SourceBuffer in segments mode. A state track 21 + (watch channel) delivers current playback position, queue, and history as JSON. 22 + Chat is persisted to SQLite, replayed on subscribe (last N), then live.
+71
flake.nix
··· 1 + { 2 + description = "moqbox — Media over QUIC with SSR frontend"; 3 + 4 + inputs = { 5 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 6 + flake-parts.url = "github:hercules-ci/flake-parts"; 7 + rust-overlay.url = "github:oxalica/rust-overlay"; 8 + crane.url = "github:ipetkov/crane"; 9 + }; 10 + 11 + outputs = 12 + inputs@{ 13 + self, 14 + flake-parts, 15 + crane, 16 + ... 17 + }: 18 + flake-parts.lib.mkFlake { inherit inputs; } { 19 + systems = [ 20 + "x86_64-linux" 21 + "aarch64-linux" 22 + "x86_64-darwin" 23 + "aarch64-darwin" 24 + ]; 25 + 26 + perSystem = 27 + { system, ... }: 28 + let 29 + pkgs = import inputs.nixpkgs { 30 + inherit system; 31 + overlays = [ inputs.rust-overlay.overlays.default ]; 32 + }; 33 + 34 + rustToolchain = pkgs.rust-bin.selectLatestNightlyWith ( 35 + toolchain: 36 + toolchain.default.override { 37 + extensions = [ 38 + "rust-src" 39 + "rust-analyzer" 40 + "clippy" 41 + "llvm-tools" 42 + ]; 43 + } 44 + ); 45 + 46 + craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain; 47 + in 48 + { 49 + devShells.default = craneLib.devShell { 50 + checks = self.checks.${system}; 51 + 52 + packages = with pkgs; [ 53 + rustToolchain 54 + cargo-watch 55 + cargo-edit 56 + cargo-audit 57 + cargo-nextest 58 + cargo-expand 59 + cargo-insta 60 + cargo-deny 61 + yt-dlp 62 + ffmpeg 63 + openssl 64 + pkg-config 65 + ]; 66 + }; 67 + 68 + formatter = pkgs.nixfmt; 69 + }; 70 + }; 71 + }
+3
rust-toolchain.toml
··· 1 + [toolchain] 2 + channel = "nightly" 3 + components = ["rust-src", "rust-analyzer", "clippy", "llvm-tools"]
+239
src/db.rs
··· 1 + //! SQLite persistence layer for rooms, queue items, and chat messages. 2 + 3 + use std::path::Path; 4 + 5 + use sqlx::sqlite::SqlitePoolOptions; 6 + use sqlx::{Pool, Sqlite}; 7 + 8 + /// Create a SQLite connection pool at `path` with WAL journal mode. 9 + pub(crate) async fn create_pool(path: &Path) -> Result<Pool<Sqlite>, sqlx::Error> { 10 + let conn_str = format!("sqlite:{}?mode=rwc", path.to_string_lossy()); 11 + let pool = SqlitePoolOptions::new() 12 + .max_connections(8) 13 + .connect(&conn_str) 14 + .await?; 15 + 16 + // Connection-safe PRAGMAs applied on each connection. 17 + for pragma in [ 18 + "PRAGMA journal_mode=wal", 19 + "PRAGMA synchronous=NORMAL", 20 + "PRAGMA foreign_keys=ON", 21 + "PRAGMA busy_timeout=5000", 22 + ] { 23 + sqlx::query(pragma).execute(&pool).await?; 24 + } 25 + 26 + Ok(pool) 27 + } 28 + 29 + /// Run all migrations (idempotent — uses `CREATE TABLE IF NOT EXISTS`). 30 + pub(crate) async fn run_migrations(pool: &Pool<Sqlite>) -> Result<(), sqlx::Error> { 31 + sqlx::query( 32 + "CREATE TABLE IF NOT EXISTS rooms ( 33 + id TEXT PRIMARY KEY, 34 + created_at TEXT NOT NULL, 35 + updated_at TEXT NOT NULL, 36 + persistent INTEGER NOT NULL DEFAULT 0 37 + )", 38 + ) 39 + .execute(pool) 40 + .await?; 41 + 42 + sqlx::query( 43 + "CREATE TABLE IF NOT EXISTS queue_items ( 44 + id INTEGER PRIMARY KEY AUTOINCREMENT, 45 + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, 46 + url TEXT NOT NULL, 47 + title TEXT NOT NULL, 48 + duration TEXT NOT NULL DEFAULT '--:--', 49 + thumbnail TEXT, 50 + extractor TEXT NOT NULL DEFAULT '', 51 + added_by TEXT, 52 + position INTEGER NOT NULL, 53 + added_at TEXT NOT NULL, 54 + played INTEGER NOT NULL DEFAULT 0, 55 + played_at TEXT 56 + )", 57 + ) 58 + .execute(pool) 59 + .await?; 60 + 61 + sqlx::query( 62 + "CREATE TABLE IF NOT EXISTS chat_messages ( 63 + id INTEGER PRIMARY KEY AUTOINCREMENT, 64 + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, 65 + user_name TEXT NOT NULL, 66 + content TEXT NOT NULL, 67 + msg_type TEXT NOT NULL DEFAULT 'message', 68 + created_at TEXT NOT NULL 69 + )", 70 + ) 71 + .execute(pool) 72 + .await?; 73 + 74 + // Migration: add started_playing_at to queue_items (separates "playing" from "played"). 75 + let has_column: bool = sqlx::query_scalar( 76 + "SELECT COUNT(*) FROM pragma_table_info('queue_items') WHERE name = 'started_playing_at'", 77 + ) 78 + .fetch_one(pool) 79 + .await 80 + .map(|c: i64| c > 0) 81 + .unwrap_or(false); 82 + 83 + if !has_column { 84 + sqlx::query("ALTER TABLE queue_items ADD COLUMN started_playing_at TEXT") 85 + .execute(pool) 86 + .await?; 87 + } 88 + 89 + Ok(()) 90 + } 91 + 92 + fn now_iso() -> String { 93 + chrono::Utc::now() 94 + .format("%Y-%m-%dT%H:%M:%S%.3fZ") 95 + .to_string() 96 + } 97 + 98 + pub(crate) struct NewChatMessage { 99 + pub(crate) room_id: String, 100 + pub(crate) user_name: String, 101 + pub(crate) content: String, 102 + pub(crate) msg_type: String, 103 + pub(crate) created_at: String, 104 + } 105 + 106 + #[derive(Debug, sqlx::FromRow)] 107 + pub(crate) struct ChatMessageRow { 108 + pub(crate) id: i64, 109 + pub(crate) user_name: String, 110 + pub(crate) content: String, 111 + pub(crate) msg_type: String, 112 + pub(crate) created_at: String, 113 + } 114 + 115 + pub(crate) async fn room_insert( 116 + pool: &Pool<Sqlite>, 117 + id: &str, 118 + persistent: bool, 119 + ) -> Result<(), sqlx::Error> { 120 + let now = now_iso(); 121 + sqlx::query( 122 + "INSERT INTO rooms (id, created_at, updated_at, persistent) VALUES (?1, ?2, ?3, ?4)", 123 + ) 124 + .bind(id) 125 + .bind(&now) 126 + .bind(&now) 127 + .bind(persistent as i32) 128 + .execute(pool) 129 + .await?; 130 + Ok(()) 131 + } 132 + 133 + pub(crate) async fn room_exists(pool: &Pool<Sqlite>, id: &str) -> Result<bool, sqlx::Error> { 134 + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM rooms WHERE id = ?") 135 + .bind(id) 136 + .fetch_one(pool) 137 + .await?; 138 + Ok(count > 0) 139 + } 140 + 141 + pub(crate) async fn room_delete(pool: &Pool<Sqlite>, id: &str) -> Result<(), sqlx::Error> { 142 + sqlx::query("DELETE FROM rooms WHERE id = ?") 143 + .bind(id) 144 + .execute(pool) 145 + .await?; 146 + Ok(()) 147 + } 148 + 149 + pub(crate) async fn chat_insert( 150 + pool: &Pool<Sqlite>, 151 + msg: &NewChatMessage, 152 + ) -> Result<i64, sqlx::Error> { 153 + let result = sqlx::query( 154 + "INSERT INTO chat_messages (room_id, user_name, content, msg_type, created_at) 155 + VALUES (?1, ?2, ?3, ?4, ?5)", 156 + ) 157 + .bind(&msg.room_id) 158 + .bind(&msg.user_name) 159 + .bind(&msg.content) 160 + .bind(&msg.msg_type) 161 + .bind(&msg.created_at) 162 + .execute(pool) 163 + .await?; 164 + Ok(result.last_insert_rowid()) 165 + } 166 + 167 + pub(crate) async fn chat_recent( 168 + pool: &Pool<Sqlite>, 169 + room_id: &str, 170 + limit: i64, 171 + ) -> Result<Vec<ChatMessageRow>, sqlx::Error> { 172 + let rows = sqlx::query_as::<_, ChatMessageRow>( 173 + "SELECT id, user_name, content, msg_type, created_at 174 + FROM chat_messages WHERE room_id = ? ORDER BY id DESC LIMIT ?", 175 + ) 176 + .bind(room_id) 177 + .bind(limit) 178 + .fetch_all(pool) 179 + .await?; 180 + Ok(rows) 181 + } 182 + 183 + #[cfg(test)] 184 + mod tests { 185 + use super::*; 186 + 187 + async fn setup() -> Pool<Sqlite> { 188 + let pool = SqlitePoolOptions::new() 189 + .max_connections(1) 190 + .connect(":memory:") 191 + .await 192 + .unwrap(); 193 + run_migrations(&pool).await.unwrap(); 194 + pool 195 + } 196 + 197 + #[tokio::test] 198 + async fn test_migrations() { 199 + let pool = setup().await; 200 + let count: i64 = sqlx::query_scalar( 201 + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('rooms', 'queue_items', 'chat_messages')", 202 + ) 203 + .fetch_one(&pool) 204 + .await 205 + .unwrap(); 206 + assert_eq!(count, 3); 207 + } 208 + 209 + #[tokio::test] 210 + async fn test_room_crud() { 211 + let pool = setup().await; 212 + room_insert(&pool, "test-room", false).await.unwrap(); 213 + assert!(room_exists(&pool, "test-room").await.unwrap()); 214 + room_delete(&pool, "test-room").await.unwrap(); 215 + assert!(!room_exists(&pool, "test-room").await.unwrap()); 216 + } 217 + 218 + #[tokio::test] 219 + async fn test_chat_crud() { 220 + let pool = setup().await; 221 + room_insert(&pool, "test-room", false).await.unwrap(); 222 + 223 + for i in 0..5 { 224 + let msg = NewChatMessage { 225 + room_id: "test-room".into(), 226 + user_name: "alice".into(), 227 + content: format!("message {i}"), 228 + msg_type: "message".into(), 229 + created_at: now_iso(), 230 + }; 231 + chat_insert(&pool, &msg).await.unwrap(); 232 + } 233 + 234 + let recent = chat_recent(&pool, "test-room", 3).await.unwrap(); 235 + assert_eq!(recent.len(), 3); 236 + assert_eq!(recent[0].content, "message 4"); 237 + assert_eq!(recent[2].content, "message 2"); 238 + } 239 + }
+112
src/main.rs
··· 1 + //! moqbox — Ultra-low latency shared media rooms over MoQ. 2 + 3 + #![deny(rust_2018_idioms, unsafe_code)] 4 + #![cfg_attr(not(test), deny(clippy::unwrap_used))] 5 + 6 + mod db; 7 + mod media; 8 + mod playlist; 9 + mod room; 10 + mod transport; 11 + mod types; 12 + mod web; 13 + 14 + use clap::Parser; 15 + use std::net::SocketAddr; 16 + use std::path::PathBuf; 17 + use std::time::Duration; 18 + use tracing_subscriber::EnvFilter; 19 + 20 + #[derive(Parser)] 21 + #[command(name = "moqbox", version, about = "Shared media rooms over MoQ")] 22 + struct Args { 23 + /// HTTP listen address (SSR frontend + API). 24 + #[arg(long, default_value = "0.0.0.0:3000", env = "MOQBOX_HTTP_ADDR")] 25 + http_addr: SocketAddr, 26 + 27 + /// Directory for yt-dlp temporary downloads. 28 + #[arg(long, default_value = "/tmp/moqbox", env = "MOQBOX_CACHE_DIR")] 29 + cache_dir: PathBuf, 30 + 31 + /// Path to SQLite database file. 32 + #[arg(long, default_value = "moqbox.db", env = "MOQBOX_DB_PATH")] 33 + db_path: PathBuf, 34 + } 35 + 36 + #[tokio::main] 37 + async fn main() -> anyhow::Result<()> { 38 + tracing_subscriber::fmt() 39 + .with_env_filter(EnvFilter::from_default_env()) 40 + .init(); 41 + 42 + let args = Args::parse(); 43 + 44 + // Ensure cache directory exists. 45 + tokio::fs::create_dir_all(&args.cache_dir).await?; 46 + 47 + // Ensure database parent directory exists. 48 + if let Some(parent) = args.db_path.parent() { 49 + if !parent.as_os_str().is_empty() { 50 + tokio::fs::create_dir_all(parent).await?; 51 + } 52 + } 53 + 54 + let pool = db::create_pool(&args.db_path).await?; 55 + db::run_migrations(&pool).await?; 56 + tracing::info!("database ready at {}", args.db_path.display()); 57 + 58 + let rooms = room::Registry::new(pool.clone(), args.cache_dir.clone()); 59 + 60 + // Idle room sweeper: every 60s, remove rooms idle for 600s (10 min). 61 + let registry_clone = rooms.clone(); 62 + tokio::spawn(async move { 63 + let mut interval = tokio::time::interval(Duration::from_secs(60)); 64 + loop { 65 + interval.tick().await; 66 + let swept = registry_clone.sweep_idle(Duration::from_secs(600)).await; 67 + if !swept.is_empty() { 68 + tracing::info!("swept {} idle rooms", swept.len()); 69 + } 70 + } 71 + }); 72 + 73 + let app = web::router(rooms, pool, args.cache_dir); 74 + let listener = tokio::net::TcpListener::bind(args.http_addr).await?; 75 + 76 + tracing::info!("HTTP server listening on {}", args.http_addr); 77 + 78 + axum::serve( 79 + listener, 80 + app.into_make_service_with_connect_info::<SocketAddr>(), 81 + ) 82 + .with_graceful_shutdown(shutdown_signal()) 83 + .await?; 84 + 85 + Ok(()) 86 + } 87 + 88 + async fn shutdown_signal() { 89 + let ctrl_c = async { 90 + tokio::signal::ctrl_c() 91 + .await 92 + .expect("failed to install Ctrl+C handler"); 93 + }; 94 + 95 + #[cfg(unix)] 96 + let terminate = async { 97 + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) 98 + .expect("failed to install SIGTERM handler") 99 + .recv() 100 + .await; 101 + }; 102 + 103 + #[cfg(not(unix))] 104 + let terminate = std::future::pending::<()>(); 105 + 106 + tokio::select! { 107 + _ = ctrl_c => {}, 108 + _ = terminate => {}, 109 + } 110 + 111 + tracing::info!("shutting down"); 112 + }
+526
src/media.rs
··· 1 + //! Media extraction and transcoding pipeline. 2 + //! 3 + //! Wraps `yt-dlp` for metadata extraction and stream URL resolution, 4 + //! and `ffmpeg` for transcoding to raw fMP4 chunks on stdout. 5 + //! No fMP4 parsing — just reads stdout chunks and publishes as MoQ objects. 6 + 7 + use std::path::Path; 8 + 9 + use bytes::Bytes; 10 + use serde::Deserialize; 11 + use tokio::io::AsyncReadExt; 12 + use tokio::process::Command; 13 + 14 + use crate::types::{TrackMeta, TrackPublishers}; 15 + 16 + /// Errors that can occur during media pipeline operations. 17 + #[derive(Debug, thiserror::Error)] 18 + pub(crate) enum MediaError { 19 + #[error("extraction failed: {0}")] 20 + Extraction(String), 21 + #[error("transcoding failed: {0}")] 22 + Transcode(String), 23 + #[error("io error: {0}")] 24 + Io(#[from] std::io::Error), 25 + #[error("pipeline aborted")] 26 + Aborted, 27 + } 28 + 29 + /// Raw yt-dlp `--dump-json` output fields we care about. 30 + #[derive(Debug, Deserialize)] 31 + #[serde(rename_all = "snake_case")] 32 + struct YtdlpMetadata { 33 + title: String, 34 + duration: Option<f64>, 35 + thumbnail: Option<String>, 36 + webpage_url: String, 37 + #[serde(default)] 38 + extractor: String, 39 + } 40 + 41 + /// Spawn FFmpeg pipeline and publish raw fMP4 chunks as MoQ objects. 42 + /// 43 + /// Returns `Ok(())` on normal EOF, `Err` on failure. 44 + pub(crate) async fn spawn_pipeline( 45 + url: &str, 46 + cache_dir: &Path, 47 + publishers: TrackPublishers, 48 + mut abort: tokio::sync::watch::Receiver<bool>, 49 + group_id: u64, 50 + ) -> Result<(), MediaError> { 51 + tracing::debug!(url, "spawning ffmpeg pipeline"); 52 + 53 + // 1. Get direct stream URL from yt-dlp (best single-file format). 54 + // Using -f b ensures a combined audio+video URL; -g alone returns 55 + // separate audio and video URLs which ffmpeg can't use with one -i. 56 + let output = Command::new("yt-dlp") 57 + .args([ 58 + "-f", 59 + "b", 60 + "-g", 61 + "--no-playlist", 62 + "--no-warnings", 63 + "--cache-dir", 64 + ]) 65 + .arg(cache_dir) 66 + .arg(url) 67 + .kill_on_drop(true) 68 + .output() 69 + .await 70 + .map_err(MediaError::Io)?; 71 + 72 + if !output.status.success() { 73 + let stderr = String::from_utf8_lossy(&output.stderr); 74 + return Err(MediaError::Extraction(stderr.trim().to_string())); 75 + } 76 + 77 + let stream_url = String::from_utf8(output.stdout) 78 + .map_err(|_| MediaError::Extraction("invalid UTF-8 from yt-dlp".into()))? 79 + .trim() 80 + .to_string(); 81 + 82 + if stream_url.is_empty() { 83 + return Err(MediaError::Extraction( 84 + "yt-dlp returned empty stream URL".into(), 85 + )); 86 + } 87 + // 2. Spawn FFmpeg transcoding to fMP4 on stdout. 88 + // -re throttles the input to native frame rate so output 89 + // stays in sync with wall-clock time. Without it FFmpeg 90 + // transcodes faster than real-time, making late-join 91 + // position calculations impossible. 92 + let mut ffmpeg = Command::new("ffmpeg") 93 + .args([ 94 + "-re", 95 + "-i", 96 + &stream_url, 97 + "-c:v", 98 + "libx264", 99 + "-profile:v", 100 + "main", 101 + "-preset", 102 + "veryfast", 103 + "-tune", 104 + "zerolatency", 105 + "-c:a", 106 + "aac", 107 + "-b:a", 108 + "128k", 109 + "-f", 110 + "mp4", 111 + "-movflags", 112 + "frag_keyframe+empty_moov+default_base_moof", 113 + "-g", 114 + "30", 115 + "-r", 116 + "30", 117 + "-loglevel", 118 + "error", 119 + "pipe:1", 120 + ]) 121 + .stdout(std::process::Stdio::piped()) 122 + .stderr(std::process::Stdio::piped()) 123 + .kill_on_drop(true) 124 + .spawn() 125 + .map_err(|e| MediaError::Transcode(format!("failed to spawn ffmpeg: {e}")))?; 126 + 127 + tracing::debug!("ffmpeg pipeline started"); 128 + 129 + let mut stdout = ffmpeg 130 + .stdout 131 + .take() 132 + .ok_or_else(|| MediaError::Transcode("no stdout from ffmpeg".into()))?; 133 + 134 + // Spawn background task to drain stderr (prevents pipe deadlock at 64KB). 135 + let stderr_drain = ffmpeg.stderr.take().map(|stderr| { 136 + tokio::spawn(async move { 137 + let mut buf = String::new(); 138 + let mut reader = tokio::io::BufReader::new(stderr); 139 + reader.read_to_string(&mut buf).await.ok(); 140 + buf 141 + }) 142 + }); 143 + 144 + // 3. Read and publish each fMP4 box as a MoQ object. 145 + // Box format: [4-byte big-endian size][4-byte type][payload] 146 + // Publishing complete boxes ensures MSE can consume them directly. 147 + read_and_publish_boxes(&mut stdout, &publishers, &mut abort, group_id).await?; 148 + 149 + // 4. Wait for FFmpeg to exit 150 + let status = ffmpeg 151 + .wait() 152 + .await 153 + .map_err(|e| MediaError::Transcode(format!("ffmpeg wait failed: {e}")))?; 154 + 155 + // 5. Collect stderr output (background task already completed since 156 + // ffmpeg closed stderr on exit). 157 + let stderr_output = match stderr_drain { 158 + Some(handle) => handle.await.unwrap_or_default(), 159 + None => String::new(), 160 + }; 161 + 162 + if !status.success() { 163 + tracing::warn!("ffmpeg exited with status {status}; stderr: {stderr_output}"); 164 + // Still return Ok — EOF is EOF even if ffmpeg reported an error at the end 165 + } 166 + 167 + tracing::info!(status = %status, stderr_len = %stderr_output.len(), "ffmpeg pipeline finished"); 168 + 169 + Ok(()) 170 + } 171 + 172 + /// Read fMP4 boxes from FFmpeg stdout, buffer moof+mdat pairs, and publish 173 + /// each complete segment as a single MoQ object. 174 + /// 175 + /// Box format: [4-byte big-endian size][4-byte type][payload] 176 + /// 177 + /// MSE requires moof and its following mdat to be appended as one buffer. 178 + /// This function buffers moof, waits for the next mdat, concatenates them, 179 + /// and publishes the pair as a single object. 180 + /// 181 + /// Returns Ok on EOF, Err on read failure. 182 + async fn read_and_publish_boxes( 183 + stdout: &mut (impl tokio::io::AsyncRead + Unpin), 184 + publishers: &TrackPublishers, 185 + abort: &mut tokio::sync::watch::Receiver<bool>, 186 + group_id: u64, 187 + ) -> Result<(), MediaError> { 188 + // Check abort signal before starting (handles pre-signalled aborts). 189 + if *abort.borrow() { 190 + tracing::debug!("pipeline aborted before starting"); 191 + return Err(MediaError::Aborted); 192 + } 193 + 194 + let mut object_id: u64 = 0; 195 + let mut init_data = Vec::new(); 196 + let mut pending_moof: Option<Vec<u8>> = None; 197 + 198 + loop { 199 + // Read 8-byte box header 200 + let mut header = [0u8; 8]; 201 + tokio::select! { 202 + _ = abort.changed() => return Err(MediaError::Aborted), 203 + result = stdout.read_exact(&mut header) => { 204 + match result { 205 + Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()), 206 + Err(e) => return Err(MediaError::Io(e)), 207 + Ok(_) => {} 208 + } 209 + } 210 + } 211 + 212 + let box_type = [header[4], header[5], header[6], header[7]]; 213 + let box_data = read_box_payload(&mut *stdout, &header).await?; 214 + 215 + match &box_type { 216 + b"ftyp" => { 217 + // Buffer ftyp — wait for moov before publishing init segment. 218 + init_data = box_data.to_vec(); 219 + } 220 + b"moov" => { 221 + // Concatenate with buffered ftyp and publish as one init segment. 222 + if !init_data.is_empty() { 223 + let mut init = std::mem::take(&mut init_data); 224 + init.extend_from_slice(&box_data); 225 + publishers.cache_init_segment(Bytes::copy_from_slice(&init), group_id); 226 + // Init is NOT published to broadcast — the forward task sends 227 + // it explicitly from cache so late joiners can initialise. 228 + object_id += 1; 229 + } else { 230 + publishers.publish_video(group_id, object_id, Bytes::from(box_data)); 231 + object_id += 1; 232 + } 233 + } 234 + b"moof" => { 235 + pending_moof = Some(box_data.to_vec()); 236 + } 237 + b"mdat" => { 238 + if let Some(moof) = pending_moof.take() { 239 + let mut combined = moof; 240 + combined.extend_from_slice(&box_data); 241 + publishers.publish_video(group_id, object_id, Bytes::from(combined)); 242 + object_id += 1; 243 + } else { 244 + publishers.publish_video(group_id, object_id, Bytes::from(box_data)); 245 + object_id += 1; 246 + } 247 + } 248 + _ => { 249 + publishers.publish_video(group_id, object_id, Bytes::from(box_data)); 250 + object_id += 1; 251 + } 252 + } 253 + } 254 + } 255 + 256 + /// Read the complete bytes of an ISOBMFF box given its 8-byte header. 257 + /// 258 + /// Returns the full box bytes (header + payload, including extended-size 259 + /// field if applicable), suitable for MSE SourceBuffer.appendBuffer(). 260 + async fn read_box_payload<R: tokio::io::AsyncRead + Unpin>( 261 + reader: &mut R, 262 + header: &[u8; 8], 263 + ) -> Result<Vec<u8>, MediaError> { 264 + let size = u32::from_be_bytes([header[0], header[1], header[2], header[3]]); 265 + 266 + if size == 0 { 267 + // Last box — read all remaining data. 268 + let mut remaining = Vec::new(); 269 + reader.read_to_end(&mut remaining).await?; 270 + let mut full = header.to_vec(); 271 + full.extend(remaining); 272 + return Ok(full); 273 + } 274 + 275 + if size == 1 { 276 + // Extended size: 8 more bytes of total size. 277 + let mut ext = [0u8; 8]; 278 + reader.read_exact(&mut ext).await?; 279 + let total = u64::from_be_bytes(ext) as usize; 280 + let payload_len = total - 16; // header(8) + ext(8) 281 + let mut payload = vec![0u8; payload_len]; 282 + reader.read_exact(&mut payload).await?; 283 + let mut full = Vec::with_capacity(total); 284 + full.extend_from_slice(header); 285 + full.extend_from_slice(&ext); 286 + full.extend_from_slice(&payload); 287 + return Ok(full); 288 + } 289 + 290 + let payload_len = size as usize - 8; 291 + let mut payload = vec![0u8; payload_len]; 292 + reader.read_exact(&mut payload).await?; 293 + let mut full = Vec::with_capacity(size as usize); 294 + full.extend_from_slice(header); 295 + full.extend_from_slice(&payload); 296 + Ok(full) 297 + } 298 + 299 + /// Run `yt-dlp --dump-json <url>` and parse the result. 300 + /// 301 + /// Returns a [`TrackMeta`] suitable for the room queue. 302 + /// 303 + /// # Errors 304 + /// 305 + /// Returns an error if yt-dlp is not installed, the URL is not 306 + /// extractable, or the process times out. 307 + pub(crate) async fn extract(url: &str, cache_dir: &Path) -> anyhow::Result<TrackMeta> { 308 + if !url.starts_with("http://") && !url.starts_with("https://") { 309 + anyhow::bail!("URL must start with http:// or https://"); 310 + } 311 + 312 + let output = Command::new("yt-dlp") 313 + .args([ 314 + "--dump-json", 315 + "--no-playlist", 316 + "--no-warnings", 317 + "--cache-dir", 318 + ]) 319 + .arg(cache_dir) 320 + .arg(url) 321 + .kill_on_drop(true) 322 + .output() 323 + .await?; 324 + 325 + if !output.status.success() { 326 + let stderr = String::from_utf8_lossy(&output.stderr); 327 + anyhow::bail!("yt-dlp failed: {}", stderr.trim()); 328 + } 329 + 330 + let raw: YtdlpMetadata = serde_json::from_slice(&output.stdout)?; 331 + 332 + let duration = raw 333 + .duration 334 + .map(format_duration) 335 + .unwrap_or_else(|| "--:--".into()); 336 + 337 + Ok(TrackMeta { 338 + title: raw.title, 339 + duration, 340 + thumbnail: raw.thumbnail, 341 + url: raw.webpage_url, 342 + extractor: raw.extractor, 343 + }) 344 + } 345 + 346 + fn format_duration(total_secs: f64) -> String { 347 + let total = total_secs as u64; 348 + let hours = total / 3600; 349 + let minutes = (total % 3600) / 60; 350 + let seconds = total % 60; 351 + 352 + if hours > 0 { 353 + format!("{hours}:{minutes:02}:{seconds:02}") 354 + } else { 355 + format!("{minutes}:{seconds:02}") 356 + } 357 + } 358 + 359 + #[cfg(test)] 360 + mod tests { 361 + use super::*; 362 + use std::path::Path; 363 + 364 + #[test] 365 + fn test_format_duration() { 366 + assert_eq!(format_duration(0.0), "0:00"); 367 + assert_eq!(format_duration(65.0), "1:05"); 368 + assert_eq!(format_duration(3661.0), "1:01:01"); 369 + } 370 + 371 + #[test] 372 + fn test_extract_rejects_bad_urls() { 373 + let rt = tokio::runtime::Runtime::new().unwrap(); 374 + let result = rt.block_on(extract("javascript:alert(1)", Path::new("/tmp"))); 375 + assert!(result.is_err()); 376 + assert!(result.unwrap_err().to_string().contains("http")); 377 + } 378 + 379 + #[tokio::test] 380 + async fn test_moof_mdat_combining() { 381 + fn make_box(box_type: &[u8; 4], payload: &[u8]) -> Vec<u8> { 382 + let size = 8 + payload.len() as u32; 383 + let mut buf = Vec::with_capacity(size as usize); 384 + buf.extend_from_slice(&size.to_be_bytes()); 385 + buf.extend_from_slice(box_type); 386 + buf.extend_from_slice(payload); 387 + buf 388 + } 389 + 390 + // Build a minimal fMP4 sequence: ftyp, moov, moof, mdat. 391 + let ftyp = make_box(b"ftyp", b"iso5"); 392 + let moov = make_box(b"moov", b"moovdata"); 393 + let moof = make_box(b"moof", b"moofdata"); 394 + let mdat = make_box(b"mdat", b"mdatdata"); 395 + 396 + let mut input = Vec::new(); 397 + input.extend_from_slice(&ftyp); 398 + input.extend_from_slice(&moov); 399 + input.extend_from_slice(&moof); 400 + input.extend_from_slice(&mdat); 401 + 402 + let publishers = TrackPublishers::new(); 403 + let mut rx = publishers.video.subscribe(); 404 + let (_tx, mut abort_rx) = tokio::sync::watch::channel(false); 405 + 406 + let mut slice: &[u8] = &input; 407 + read_and_publish_boxes(&mut slice, &publishers, &mut abort_rx, 0) 408 + .await 409 + .expect("read_and_publish_boxes should succeed on valid fMP4"); 410 + 411 + // Init segment is NOT published to broadcast — only cached. 412 + // Verify the cache contains ftyp+moov. 413 + let cached = publishers.get_init_segment(); 414 + assert!(cached.is_some(), "init cache should contain ftyp+moov"); 415 + let (cached_group, cached_data) = cached.unwrap(); 416 + assert_eq!(cached_group, 0); 417 + let mut expected_init = ftyp.clone(); 418 + expected_init.extend_from_slice(&moov); 419 + assert_eq!(cached_data.to_vec(), expected_init); 420 + 421 + // Object 1: moof+mdat combined into one media segment 422 + // (object_id=0 was skipped — it's reserved for the forward task's cached init) 423 + let obj1 = rx.recv().await.expect("should receive combined moof+mdat"); 424 + assert_eq!(obj1.object_id, 1); 425 + let mut expected_media = moof.clone(); 426 + expected_media.extend_from_slice(&mdat); 427 + assert_eq!(obj1.payload.to_vec(), expected_media); 428 + } 429 + 430 + /// Verify init segment (ftyp+moov) is cached but NOT published to broadcast. 431 + /// Only the moof+mdat media segment should appear on the broadcast channel, 432 + /// with object_id=1 (object_id=0 is reserved for the forward task's cached init). 433 + #[tokio::test] 434 + async fn test_init_not_published_to_broadcast() { 435 + fn make_box(box_type: &[u8; 4], payload: &[u8]) -> Vec<u8> { 436 + let size = 8 + payload.len() as u32; 437 + let mut buf = Vec::with_capacity(size as usize); 438 + buf.extend_from_slice(&size.to_be_bytes()); 439 + buf.extend_from_slice(box_type); 440 + buf.extend_from_slice(payload); 441 + buf 442 + } 443 + 444 + let ftyp = make_box(b"ftyp", b"iso5"); 445 + let moov = make_box(b"moov", b"moovdata"); 446 + let moof = make_box(b"moof", b"moofdata"); 447 + let mdat = make_box(b"mdat", b"datadata"); 448 + 449 + let mut input = Vec::new(); 450 + input.extend_from_slice(&ftyp); 451 + input.extend_from_slice(&moov); 452 + input.extend_from_slice(&moof); 453 + input.extend_from_slice(&mdat); 454 + 455 + let publishers = TrackPublishers::new(); 456 + let mut rx = publishers.video.subscribe(); 457 + let (_tx, mut abort_rx) = tokio::sync::watch::channel(false); 458 + 459 + let mut slice: &[u8] = &input; 460 + read_and_publish_boxes(&mut slice, &publishers, &mut abort_rx, 0) 461 + .await 462 + .expect("read_and_publish_boxes should succeed"); 463 + 464 + // Verify init is cached but NOT broadcast. 465 + let cached = publishers.get_init_segment(); 466 + assert!(cached.is_some(), "init cache should contain ftyp+moov"); 467 + let (cached_group, cached_data) = cached.unwrap(); 468 + assert_eq!(cached_group, 0); 469 + let mut expected_init = ftyp.clone(); 470 + expected_init.extend_from_slice(&moov); 471 + assert_eq!(cached_data.to_vec(), expected_init); 472 + 473 + // Only one broadcast message: moof+mdat at object_id=1. 474 + let media = rx.recv().await.expect("should receive media segment"); 475 + assert_eq!( 476 + media.object_id, 1, 477 + "media segment should have object_id=1 (0 reserved for cached init)" 478 + ); 479 + assert_eq!(media.group_id, 0); 480 + let mut expected_media = moof.clone(); 481 + expected_media.extend_from_slice(&mdat); 482 + assert_eq!(media.payload.to_vec(), expected_media); 483 + 484 + // Confirm no second broadcast message. 485 + match tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()).await { 486 + Err(tokio::time::error::Elapsed { .. }) => {} // expected: no more messages 487 + Ok(_) => panic!("should not receive a second broadcast message"), 488 + } 489 + } 490 + 491 + /// Verify get_init_segment returns the correct group_id. 492 + #[tokio::test] 493 + async fn test_init_cache_has_group_id() { 494 + fn make_box(box_type: &[u8; 4], payload: &[u8]) -> Vec<u8> { 495 + let size = 8 + payload.len() as u32; 496 + let mut buf = Vec::with_capacity(size as usize); 497 + buf.extend_from_slice(&size.to_be_bytes()); 498 + buf.extend_from_slice(box_type); 499 + buf.extend_from_slice(payload); 500 + buf 501 + } 502 + 503 + let ftyp = make_box(b"ftyp", b"iso5"); 504 + let moov = make_box(b"moov", b"moovdata"); 505 + 506 + let mut input = Vec::new(); 507 + input.extend_from_slice(&ftyp); 508 + input.extend_from_slice(&moov); 509 + 510 + let publishers = TrackPublishers::new(); 511 + let (_tx, mut abort_rx) = tokio::sync::watch::channel(false); 512 + 513 + let mut slice: &[u8] = &input; 514 + read_and_publish_boxes(&mut slice, &publishers, &mut abort_rx, 42) 515 + .await 516 + .expect("read_and_publish_boxes should succeed"); 517 + 518 + let cached = publishers.get_init_segment(); 519 + assert!(cached.is_some(), "init should be cached"); 520 + let (group_id, _data) = cached.unwrap(); 521 + assert_eq!( 522 + group_id, 42, 523 + "group_id in cache should match the input group_id" 524 + ); 525 + } 526 + }
+400
src/playlist.rs
··· 1 + //! Ordered track list for a room — upcoming queue and played history. 2 + //! 3 + //! Wraps SQLite queue operations with room-scoped queries, providing 4 + //! push, pop-next, upcoming-list, and history-list operations. 5 + 6 + use sqlx::{Pool, Sqlite}; 7 + 8 + use crate::types::TrackMeta; 9 + 10 + fn now_iso() -> String { 11 + chrono::Utc::now() 12 + .format("%Y-%m-%dT%H:%M:%S%.3fZ") 13 + .to_string() 14 + } 15 + 16 + /// A single queue item, returned from all Playlist operations. 17 + #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)] 18 + pub(crate) struct QueueItem { 19 + pub(crate) id: i64, 20 + pub(crate) url: String, 21 + pub(crate) title: String, 22 + pub(crate) duration: String, 23 + pub(crate) thumbnail: Option<String>, 24 + pub(crate) extractor: String, 25 + pub(crate) position: i32, 26 + pub(crate) played: bool, 27 + pub(crate) played_at: Option<String>, 28 + pub(crate) started_playing_at: Option<String>, 29 + } 30 + 31 + #[derive(Clone)] 32 + pub(crate) struct Playlist { 33 + pool: Pool<Sqlite>, 34 + room_id: String, 35 + } 36 + 37 + impl Playlist { 38 + pub(crate) fn new(pool: Pool<Sqlite>, room_id: &str) -> Self { 39 + Self { 40 + pool, 41 + room_id: room_id.to_string(), 42 + } 43 + } 44 + 45 + pub(crate) async fn push(&self, meta: &TrackMeta) -> Result<QueueItem, sqlx::Error> { 46 + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM queue_items WHERE room_id = ?") 47 + .bind(&self.room_id) 48 + .fetch_one(&self.pool) 49 + .await?; 50 + 51 + let position = count as i32; 52 + 53 + let result = sqlx::query( 54 + "INSERT INTO queue_items (room_id, url, title, duration, thumbnail, extractor, added_by, position, added_at, played) 55 + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 0)", 56 + ) 57 + .bind(&self.room_id) 58 + .bind(&meta.url) 59 + .bind(&meta.title) 60 + .bind(&meta.duration) 61 + .bind(&meta.thumbnail) 62 + .bind(&meta.extractor) 63 + .bind(None::<String>) // added_by 64 + .bind(position) 65 + .bind(now_iso()) 66 + .execute(&self.pool) 67 + .await?; 68 + 69 + let id = result.last_insert_rowid(); 70 + 71 + let item = sqlx::query_as::<_, QueueItem>( 72 + "SELECT id, url, title, duration, thumbnail, extractor, position, played, played_at, started_playing_at 73 + FROM queue_items WHERE id = ?", 74 + ) 75 + .bind(id) 76 + .fetch_one(&self.pool) 77 + .await?; 78 + 79 + Ok(item) 80 + } 81 + 82 + /// Atomically fetch and mark the first unplayed item as playing. 83 + /// 84 + /// Returns the item with `started_playing_at` set, or `None` when the 85 + /// queue is empty. Unlike the old `pop_next`, this does NOT set 86 + /// `played = 1` — that happens later via `mark_finished`. 87 + pub(crate) async fn pop_next(&self) -> Result<Option<QueueItem>, sqlx::Error> { 88 + let mut tx = self.pool.begin().await?; 89 + 90 + let item = sqlx::query_as::<_, QueueItem>( 91 + "SELECT id, url, title, duration, thumbnail, extractor, position, played, played_at, started_playing_at 92 + FROM queue_items WHERE room_id = ? AND played = 0 AND started_playing_at IS NULL 93 + ORDER BY position ASC LIMIT 1", 94 + ) 95 + .bind(&self.room_id) 96 + .fetch_optional(&mut *tx) 97 + .await?; 98 + 99 + let result = if let Some(item) = item { 100 + let now = now_iso(); 101 + sqlx::query("UPDATE queue_items SET started_playing_at = ? WHERE id = ?") 102 + .bind(&now) 103 + .bind(item.id) 104 + .execute(&mut *tx) 105 + .await?; 106 + 107 + Some(QueueItem { 108 + started_playing_at: Some(now), 109 + ..item 110 + }) 111 + } else { 112 + None 113 + }; 114 + 115 + tx.commit().await?; 116 + Ok(result) 117 + } 118 + 119 + /// List queued items (not playing, not finished) ordered by position ASC. 120 + pub(crate) async fn upcoming(&self) -> Result<Vec<QueueItem>, sqlx::Error> { 121 + let items = sqlx::query_as::<_, QueueItem>( 122 + "SELECT id, url, title, duration, thumbnail, extractor, position, played, played_at, started_playing_at 123 + FROM queue_items WHERE room_id = ? AND played = 0 AND started_playing_at IS NULL 124 + ORDER BY position ASC", 125 + ) 126 + .bind(&self.room_id) 127 + .fetch_all(&self.pool) 128 + .await?; 129 + Ok(items) 130 + } 131 + 132 + /// List played items ordered by played_at DESC (newest first), limited to `limit`. 133 + pub(crate) async fn history(&self, limit: i64) -> Result<Vec<QueueItem>, sqlx::Error> { 134 + let items = sqlx::query_as::<_, QueueItem>( 135 + "SELECT id, url, title, duration, thumbnail, extractor, position, played, played_at, started_playing_at 136 + FROM queue_items WHERE room_id = ? AND played = 1 ORDER BY played_at DESC, id DESC LIMIT ?", 137 + ) 138 + .bind(&self.room_id) 139 + .bind(limit) 140 + .fetch_all(&self.pool) 141 + .await?; 142 + Ok(items) 143 + } 144 + 145 + /// Mark a currently-playing item as finished (moves it from "playing" to "played"). 146 + pub(crate) async fn mark_finished(&self, id: i64) -> Result<(), sqlx::Error> { 147 + let now = now_iso(); 148 + sqlx::query( 149 + "UPDATE queue_items SET played = 1, played_at = ?, started_playing_at = NULL WHERE id = ?", 150 + ) 151 + .bind(&now) 152 + .bind(id) 153 + .execute(&self.pool) 154 + .await?; 155 + Ok(()) 156 + } 157 + } 158 + 159 + #[cfg(test)] 160 + mod tests { 161 + use super::*; 162 + use crate::db; 163 + use crate::types; 164 + use sqlx::sqlite::SqlitePoolOptions; 165 + 166 + async fn test_playlist() -> Playlist { 167 + let pool = SqlitePoolOptions::new() 168 + .max_connections(1) 169 + .connect(":memory:") 170 + .await 171 + .unwrap(); 172 + db::run_migrations(&pool).await.unwrap(); 173 + // Create a room row so foreign-key constraints are satisfied. 174 + db::room_insert(&pool, "test-room", false).await.unwrap(); 175 + Playlist::new(pool, "test-room") 176 + } 177 + 178 + fn track(title: &str) -> types::TrackMeta { 179 + types::TrackMeta { 180 + title: title.into(), 181 + duration: "3:45".into(), 182 + thumbnail: None, 183 + url: "https://example.com/track".into(), 184 + extractor: "test".into(), 185 + } 186 + } 187 + 188 + #[tokio::test] 189 + async fn test_push_and_upcoming() { 190 + let pl = test_playlist().await; 191 + 192 + let a = pl.push(&track("Track A")).await.unwrap(); 193 + assert!(a.id > 0); 194 + assert!(!a.played); 195 + assert_eq!(a.title, "Track A"); 196 + 197 + let b = pl.push(&track("Track B")).await.unwrap(); 198 + assert!(b.id > 0); 199 + assert_eq!(b.position, 1); 200 + 201 + let upcoming = pl.upcoming().await.unwrap(); 202 + assert_eq!(upcoming.len(), 2); 203 + assert_eq!(upcoming[0].title, "Track A"); 204 + assert_eq!(upcoming[1].title, "Track B"); 205 + assert!(!upcoming[0].played); 206 + assert!(!upcoming[1].played); 207 + } 208 + 209 + #[tokio::test] 210 + async fn test_pop_next() { 211 + let pl = test_playlist().await; 212 + 213 + pl.push(&track("First")).await.unwrap(); 214 + pl.push(&track("Second")).await.unwrap(); 215 + 216 + let popped = pl.pop_next().await.unwrap().expect("should pop first"); 217 + assert_eq!(popped.title, "First"); 218 + assert!(!popped.played, "pop_next should NOT mark as played"); 219 + assert!( 220 + popped.played_at.is_none(), 221 + "pop_next should NOT set played_at" 222 + ); 223 + assert!( 224 + popped.started_playing_at.is_some(), 225 + "pop_next should set started_playing_at" 226 + ); 227 + 228 + // Upcoming should exclude items that have started playing. 229 + let upcoming = pl.upcoming().await.unwrap(); 230 + assert_eq!(upcoming.len(), 1); 231 + assert_eq!(upcoming[0].title, "Second"); 232 + } 233 + 234 + #[tokio::test] 235 + async fn test_history() { 236 + let pl = test_playlist().await; 237 + 238 + pl.push(&track("A")).await.unwrap(); 239 + pl.push(&track("B")).await.unwrap(); 240 + pl.push(&track("C")).await.unwrap(); 241 + 242 + let a = pl.pop_next().await.unwrap().unwrap(); 243 + let b = pl.pop_next().await.unwrap().unwrap(); 244 + 245 + // Items only appear in history after mark_finished. 246 + let history = pl.history(10).await.unwrap(); 247 + assert_eq!( 248 + history.len(), 249 + 0, 250 + "items should not appear in history until mark_finished" 251 + ); 252 + 253 + pl.mark_finished(a.id).await.unwrap(); 254 + pl.mark_finished(b.id).await.unwrap(); 255 + 256 + let history = pl.history(10).await.unwrap(); 257 + assert_eq!(history.len(), 2); 258 + // Most recently played first. 259 + assert_eq!(history[0].title, "B"); 260 + assert_eq!(history[1].title, "A"); 261 + assert!(history[0].played); 262 + assert!(history[0].played_at.is_some()); 263 + } 264 + 265 + #[tokio::test] 266 + async fn test_pop_empty() { 267 + let pl = test_playlist().await; 268 + let popped = pl.pop_next().await.unwrap(); 269 + assert!(popped.is_none()); 270 + } 271 + 272 + #[tokio::test] 273 + async fn test_push_positions() { 274 + let pl = test_playlist().await; 275 + 276 + let a = pl.push(&track("A")).await.unwrap(); 277 + let b = pl.push(&track("B")).await.unwrap(); 278 + let c = pl.push(&track("C")).await.unwrap(); 279 + 280 + assert_eq!(a.position, 0); 281 + assert_eq!(b.position, 1); 282 + assert_eq!(c.position, 2); 283 + } 284 + 285 + #[tokio::test] 286 + async fn test_mark_finished_non_existent_id() { 287 + let pl = test_playlist().await; 288 + // Should not panic or error. 289 + let result = pl.mark_finished(99999).await; 290 + assert!( 291 + result.is_ok(), 292 + "mark_finished on non-existent id should be Ok" 293 + ); 294 + } 295 + 296 + #[tokio::test] 297 + async fn test_pop_next_returns_none_when_all_played() { 298 + let pl = test_playlist().await; 299 + pl.push(&track("A")).await.unwrap(); 300 + pl.pop_next().await.unwrap(); 301 + let next = pl.pop_next().await.unwrap(); 302 + assert!( 303 + next.is_none(), 304 + "pop_next should return None when queue is empty" 305 + ); 306 + } 307 + 308 + #[tokio::test] 309 + async fn test_upcoming_excludes_playing_items() { 310 + let pl = test_playlist().await; 311 + pl.push(&track("A")).await.unwrap(); 312 + pl.push(&track("B")).await.unwrap(); 313 + 314 + // Pop A (marks as playing, not finished). 315 + let a = pl.pop_next().await.unwrap().unwrap(); 316 + assert_eq!(a.title, "A"); 317 + assert!(!a.played, "popped item should not be marked played"); 318 + assert!( 319 + a.started_playing_at.is_some(), 320 + "popped item should have started_playing_at" 321 + ); 322 + 323 + // Upcoming should only have B. 324 + let upcoming = pl.upcoming().await.unwrap(); 325 + assert_eq!(upcoming.len(), 1, "upcoming should exclude playing item"); 326 + assert_eq!(upcoming[0].title, "B"); 327 + } 328 + 329 + #[tokio::test] 330 + async fn test_history_only_after_mark_finished() { 331 + let pl = test_playlist().await; 332 + pl.push(&track("A")).await.unwrap(); 333 + let a = pl.pop_next().await.unwrap().unwrap(); 334 + 335 + // A is playing — should NOT be in history. 336 + let history = pl.history(10).await.unwrap(); 337 + assert!( 338 + history.is_empty(), 339 + "playing items should not appear in history" 340 + ); 341 + 342 + // Mark as finished. 343 + pl.mark_finished(a.id).await.unwrap(); 344 + 345 + // Now A should be in history. 346 + let history = pl.history(10).await.unwrap(); 347 + assert_eq!(history.len(), 1); 348 + assert_eq!(history[0].title, "A"); 349 + assert!(history[0].played); 350 + assert!( 351 + history[0].played_at.is_some(), 352 + "finished items should have played_at" 353 + ); 354 + } 355 + 356 + #[tokio::test] 357 + async fn test_push_after_mark_finished() { 358 + let pl = test_playlist().await; 359 + pl.push(&track("A")).await.unwrap(); 360 + let a = pl.pop_next().await.unwrap().unwrap(); 361 + pl.mark_finished(a.id).await.unwrap(); 362 + 363 + // Push another track 364 + pl.push(&track("B")).await.unwrap(); 365 + 366 + let upcoming = pl.upcoming().await.unwrap(); 367 + assert_eq!(upcoming.len(), 1); 368 + assert_eq!(upcoming[0].title, "B"); 369 + 370 + let history = pl.history(10).await.unwrap(); 371 + assert_eq!(history.len(), 1); 372 + assert_eq!(history[0].title, "A"); 373 + } 374 + 375 + #[tokio::test] 376 + async fn test_history_order_multiple_finished() { 377 + let pl = test_playlist().await; 378 + pl.push(&track("A")).await.unwrap(); 379 + pl.push(&track("B")).await.unwrap(); 380 + pl.push(&track("C")).await.unwrap(); 381 + 382 + let a = pl.pop_next().await.unwrap().unwrap(); 383 + tokio::time::sleep(std::time::Duration::from_millis(5)).await; 384 + pl.mark_finished(a.id).await.unwrap(); 385 + 386 + let b = pl.pop_next().await.unwrap().unwrap(); 387 + tokio::time::sleep(std::time::Duration::from_millis(5)).await; 388 + pl.mark_finished(b.id).await.unwrap(); 389 + 390 + let c = pl.pop_next().await.unwrap().unwrap(); 391 + pl.mark_finished(c.id).await.unwrap(); 392 + 393 + let history = pl.history(10).await.unwrap(); 394 + assert_eq!(history.len(), 3); 395 + // Most recently finished first (C, B, A). 396 + assert_eq!(history[0].title, "C"); 397 + assert_eq!(history[1].title, "B"); 398 + assert_eq!(history[2].title, "A"); 399 + } 400 + }
+1290
src/room.rs
··· 1 + //! Room state management. 2 + //! 3 + //! Each room is identified by a snowflake ID, persists metadata in 4 + //! SQLite, and holds ephemeral playback state in memory. 5 + //! 6 + //! All mutations flow through a per-room mpsc event-loop actor, 7 + //! eliminating races, torn lock windows, and the generation counter. 8 + //! State snapshots are published automatically after every command. 9 + 10 + use std::collections::HashMap; 11 + use std::path::PathBuf; 12 + use std::sync::Arc; 13 + use std::sync::atomic::{AtomicU64, Ordering}; 14 + use std::time::{Duration, SystemTime, UNIX_EPOCH}; 15 + 16 + use bytes::Bytes; 17 + use chrono::{DateTime, Utc}; 18 + use sqlx::{Pool, Sqlite}; 19 + #[cfg(test)] 20 + use tokio::sync::oneshot; 21 + use tokio::sync::{RwLock, mpsc, watch}; 22 + 23 + use crate::db; 24 + use crate::media; 25 + use crate::playlist::{Playlist, QueueItem}; 26 + use crate::types::{ 27 + ChatMessage, HistoryEntry, QueueSummary, RoomCommand, RoomHandle, RoomId, TrackMeta, 28 + TrackPublishers, TrackState, 29 + }; 30 + 31 + /// Distributed unique ID generator (Discord-style snowflake). 32 + /// 33 + /// Bit layout: 34 + /// 1 bit — unused (always 0) 35 + /// 41 bits — timestamp in milliseconds since custom epoch 36 + /// 10 bits — node ID (0–1023) 37 + /// 12 bits — sequence number (0–4095) 38 + /// 39 + /// Thread-safety is provided by a single `AtomicU64` that packs 40 + /// `(timestamp_ms << 12) | sequence`, allowing lock-free CAS updates. 41 + pub(crate) struct SnowflakeIdGen { 42 + node_id: u16, 43 + state: AtomicU64, // (timestamp_ms << 12) | sequence (12 bits) 44 + } 45 + 46 + const CUSTOM_EPOCH_MS: u64 = 173_568_960_0000; // 2025-01-01T00:00:00Z 47 + 48 + impl SnowflakeIdGen { 49 + pub(crate) fn new(node_id: u16) -> Self { 50 + assert!(node_id < 1024, "node_id must be in 0..1024"); 51 + Self { 52 + node_id, 53 + state: AtomicU64::new(0), 54 + } 55 + } 56 + 57 + /// Generate a unique 64-bit ID. 58 + /// 59 + /// Thread-safe. Under extreme load (≥4096 IDs in the same 60 + /// millisecond) the generator yields to the tokio runtime until 61 + /// the next millisecond tick. 62 + pub(crate) async fn next_id(&self) -> u64 { 63 + loop { 64 + let now = SystemTime::now() 65 + .duration_since(UNIX_EPOCH) 66 + .expect("system clock before Unix epoch") 67 + .as_millis() as u64; 68 + let ts = now.wrapping_sub(CUSTOM_EPOCH_MS); 69 + 70 + let current = self.state.load(Ordering::Acquire); 71 + let last_ts = current >> 12; 72 + let last_seq = (current & 0xFFF) as u16; 73 + 74 + if ts > last_ts { 75 + // Advance to a new millisecond, resetting the sequence to 0. 76 + let new_val = ts << 12; 77 + if self 78 + .state 79 + .compare_exchange_weak(current, new_val, Ordering::Release, Ordering::Relaxed) 80 + .is_ok() 81 + { 82 + return (ts << 22) | ((self.node_id as u64) << 12); 83 + } 84 + // CAS lost — another thread already advanced or incremented. 85 + // Retry with fresh state. 86 + continue; 87 + } 88 + 89 + // Same millisecond: try to claim the next sequence number. 90 + let seq = last_seq + 1; 91 + if seq < 4096 { 92 + let new_val = current + 1; // increment the 12-bit sequence field 93 + if self 94 + .state 95 + .compare_exchange_weak(current, new_val, Ordering::Release, Ordering::Relaxed) 96 + .is_ok() 97 + { 98 + return (last_ts << 22) | ((self.node_id as u64) << 12) | seq as u64; 99 + } 100 + // CAS lost — another thread claimed this seq first; retry. 101 + continue; 102 + } 103 + 104 + // Per-millisecond sequence exhausted; yield and retry. 105 + tokio::time::sleep(std::time::Duration::from_millis(1)).await; 106 + } 107 + } 108 + } 109 + 110 + /// Ephemeral info about the active track, stored in-memory only. 111 + struct ActiveTrackInfo { 112 + id: i64, 113 + title: String, 114 + url: String, 115 + duration: String, 116 + /// Epoch ms when the track started — for client-side elapsed computation. 117 + started_at_wall: i64, 118 + } 119 + 120 + impl ActiveTrackInfo { 121 + fn from_item(item: &QueueItem) -> Self { 122 + Self { 123 + id: item.id, 124 + title: item.title.clone(), 125 + url: item.url.clone(), 126 + duration: item.duration.clone(), 127 + started_at_wall: chrono::Utc::now().timestamp_millis(), 128 + } 129 + } 130 + } 131 + 132 + /// Per-room event-loop actor. Owns all mutable state. 133 + struct RoomActor { 134 + rx: mpsc::Receiver<RoomCommand>, 135 + /// Clone of tx passed to pipeline tasks so they can send TrackEnded back. 136 + pipeline_tx: mpsc::Sender<RoomCommand>, 137 + room_id: RoomId, 138 + publishers: TrackPublishers, 139 + pool: Pool<Sqlite>, 140 + cache_dir: PathBuf, 141 + 142 + // Mutable state (owned by this actor, no locks needed) 143 + active_track: Option<ActiveTrackInfo>, 144 + pipeline_abort: Option<watch::Sender<bool>>, 145 + client_count: u64, 146 + next_user_id: u64, 147 + last_active_tx: watch::Sender<DateTime<Utc>>, 148 + } 149 + 150 + /// Thread-safe registry of all active rooms with SQLite persistence. 151 + #[derive(Clone)] 152 + pub(crate) struct Registry { 153 + pool: Pool<Sqlite>, 154 + id_gen: Arc<SnowflakeIdGen>, 155 + cache_dir: PathBuf, 156 + inner: Arc<RwLock<Inner>>, 157 + } 158 + 159 + struct Inner { 160 + rooms: HashMap<RoomId, RoomHandle>, 161 + } 162 + 163 + impl Registry { 164 + pub(crate) fn new(pool: Pool<Sqlite>, cache_dir: PathBuf) -> Self { 165 + Self { 166 + pool, 167 + id_gen: Arc::new(SnowflakeIdGen::new(1)), 168 + inner: Arc::new(RwLock::new(Inner { 169 + rooms: HashMap::new(), 170 + })), 171 + cache_dir, 172 + } 173 + } 174 + 175 + /// Create a new room with a snowflake ID, persist to SQLite. 176 + pub(crate) async fn create(&self) -> RoomRef { 177 + let id = RoomId(self.id_gen.next_id().await.to_string()); 178 + let now = Utc::now(); 179 + let _ = db::room_insert(&self.pool, &id.0, false).await; 180 + 181 + let publishers = TrackPublishers::new(); 182 + let (tx, rx) = mpsc::channel(256); 183 + let (last_active_tx, last_active_rx) = watch::channel(now); 184 + 185 + let actor = RoomActor { 186 + rx, 187 + pipeline_tx: tx.clone(), 188 + room_id: id.clone(), 189 + publishers: publishers.clone(), 190 + pool: self.pool.clone(), 191 + cache_dir: self.cache_dir.clone(), 192 + active_track: None, 193 + pipeline_abort: None, 194 + client_count: 0, 195 + next_user_id: 1, 196 + last_active_tx, 197 + }; 198 + tokio::spawn(actor.run()); 199 + 200 + self.inner.write().await.rooms.insert( 201 + id.clone(), 202 + RoomHandle { 203 + cmd_tx: tx, 204 + publishers, 205 + last_active: last_active_rx, 206 + }, 207 + ); 208 + 209 + RoomRef { 210 + id, 211 + _registry: self.clone(), 212 + } 213 + } 214 + 215 + /// Get the room handle if the room is active. 216 + pub(crate) async fn handle(&self, id: &RoomId) -> Option<RoomHandle> { 217 + self.inner.read().await.rooms.get(id).cloned() 218 + } 219 + 220 + /// Check whether a room exists in SQLite. 221 + pub(crate) async fn exists(&self, id: &RoomId) -> bool { 222 + db::room_exists(&self.pool, &id.0).await.unwrap_or(false) 223 + } 224 + 225 + /// Get the upcoming queue for a room from SQLite via Playlist. 226 + pub(crate) async fn queue(&self, id: &RoomId) -> Vec<TrackMeta> { 227 + let playlist = Playlist::new(self.pool.clone(), &id.0); 228 + let items = playlist.upcoming().await.unwrap_or_default(); 229 + items 230 + .into_iter() 231 + .map(|i| TrackMeta { 232 + title: i.title, 233 + duration: i.duration, 234 + thumbnail: i.thumbnail, 235 + url: i.url, 236 + extractor: i.extractor, 237 + }) 238 + .collect() 239 + } 240 + 241 + /// Push a track onto a room's queue (persisted to SQLite via Playlist). 242 + pub(crate) async fn push(&self, id: &RoomId, meta: TrackMeta) { 243 + if let Some(handle) = self.handle(id).await { 244 + let _ = handle.cmd_tx.send(RoomCommand::QueueTrack(meta)).await; 245 + } 246 + } 247 + 248 + /// Remove a room (from SQLite and in-memory state). 249 + #[cfg(test)] 250 + pub(crate) async fn remove(&self, id: &RoomId) { 251 + if let Some(handle) = self.handle(id).await { 252 + let _ = tokio::time::timeout( 253 + Duration::from_secs(1), 254 + handle.cmd_tx.send(RoomCommand::Shutdown), 255 + ) 256 + .await; 257 + } 258 + self.inner.write().await.rooms.remove(id); 259 + let _ = db::room_delete(&self.pool, &id.0).await; 260 + } 261 + 262 + /// Send a chat message: validate, send to actor, return best-effort ChatMessage. 263 + /// 264 + /// Validation happens synchronously; the actual DB insert and broadcast 265 + /// occur in the actor. The returned `id` is 0 (not the DB-assigned id). 266 + #[cfg(test)] 267 + pub(crate) async fn send_chat( 268 + &self, 269 + room_id: &RoomId, 270 + user_name: &str, 271 + content: &str, 272 + msg_type: &str, 273 + ) -> Result<ChatMessage, anyhow::Error> { 274 + if content.is_empty() { 275 + anyhow::bail!("content is empty"); 276 + } 277 + if content.len() > 2000 { 278 + anyhow::bail!("content exceeds 2000 characters"); 279 + } 280 + if user_name.is_empty() { 281 + anyhow::bail!("username is empty"); 282 + } 283 + if user_name.len() > 32 { 284 + anyhow::bail!("username exceeds 32 characters"); 285 + } 286 + if let Some(handle) = self.handle(room_id).await { 287 + let _ = handle 288 + .cmd_tx 289 + .send(RoomCommand::Chat { 290 + user_name: user_name.to_string(), 291 + content: content.to_string(), 292 + msg_type: msg_type.to_string(), 293 + }) 294 + .await; 295 + } 296 + Ok(ChatMessage { 297 + id: 0, 298 + user_name: user_name.to_string(), 299 + content: content.to_string(), 300 + msg_type: msg_type.to_string(), 301 + created_at: chrono::Utc::now() 302 + .format("%Y-%m-%dT%H:%M:%S%.3fZ") 303 + .to_string(), 304 + }) 305 + } 306 + 307 + /// Get the most recent chat messages from SQLite. 308 + pub(crate) async fn recent_chat( 309 + &self, 310 + room_id: &RoomId, 311 + limit: i64, 312 + ) -> Result<Vec<ChatMessage>, anyhow::Error> { 313 + let rows = db::chat_recent(&self.pool, &room_id.0, limit).await?; 314 + Ok(rows 315 + .into_iter() 316 + .map(|r| ChatMessage { 317 + id: r.id, 318 + user_name: r.user_name, 319 + content: r.content, 320 + msg_type: r.msg_type, 321 + created_at: r.created_at, 322 + }) 323 + .collect()) 324 + } 325 + 326 + /// Register a client in the room, returning a monotonically increasing user id. 327 + /// 328 + /// Returns `None` if the room is not active in memory. 329 + #[cfg(test)] 330 + pub(crate) async fn register_client(&self, room_id: &RoomId) -> Option<u64> { 331 + let handle = self.handle(room_id).await?; 332 + let (resp_tx, resp_rx) = oneshot::channel(); 333 + handle 334 + .cmd_tx 335 + .send(RoomCommand::RegisterClient { resp: resp_tx }) 336 + .await 337 + .ok()?; 338 + resp_rx.await.ok() 339 + } 340 + 341 + /// Unregister a client, decrementing the client count for the room. 342 + #[cfg(test)] 343 + pub(crate) async fn unregister_client(&self, room_id: &RoomId) { 344 + if let Some(handle) = self.handle(room_id).await { 345 + let _ = handle.cmd_tx.send(RoomCommand::UnregisterClient).await; 346 + } 347 + } 348 + 349 + /// Skip the current track. 350 + pub(crate) async fn skip(&self, room_id: &RoomId) { 351 + if let Some(handle) = self.handle(room_id).await { 352 + let _ = handle.cmd_tx.send(RoomCommand::Skip).await; 353 + } 354 + } 355 + 356 + /// Sweep idle rooms that have been inactive longer than `timeout`. 357 + /// 358 + /// Sends Shutdown to the actor and removes the room from both 359 + /// in-memory state and SQLite. Returns the list of swept room IDs. 360 + pub(crate) async fn sweep_idle(&self, timeout: Duration) -> Vec<RoomId> { 361 + let now = Utc::now(); 362 + let mut swept = Vec::new(); 363 + 364 + // Identify idle rooms while holding only a read lock. 365 + let idle_rooms: Vec<RoomId> = { 366 + let inner = self.inner.read().await; 367 + inner 368 + .rooms 369 + .iter() 370 + .filter(|(_, h)| { 371 + let idle = now - *h.last_active.borrow(); 372 + idle > chrono::TimeDelta::from_std(timeout).unwrap_or_default() 373 + }) 374 + .map(|(id, _)| id.clone()) 375 + .collect() 376 + }; 377 + 378 + for id in &idle_rooms { 379 + if let Some(handle) = self.handle(id).await { 380 + if tokio::time::timeout( 381 + Duration::from_secs(1), 382 + handle.cmd_tx.send(RoomCommand::Shutdown), 383 + ) 384 + .await 385 + .is_err() 386 + { 387 + tracing::warn!("sweep: room {id} channel full, forcing removal"); 388 + } 389 + } 390 + self.inner.write().await.rooms.remove(id); 391 + let _ = db::room_delete(&self.pool, &id.0).await; 392 + swept.push(id.clone()); 393 + tracing::info!("swept idle room {id}"); 394 + } 395 + 396 + swept 397 + } 398 + } 399 + 400 + /// An owned handle to a room, returned by [`Registry::create`]. 401 + /// 402 + /// Dropping this handle does not destroy the room — rooms are torn 403 + /// down by an idle timeout sweeper. 404 + pub(crate) struct RoomRef { 405 + pub id: RoomId, 406 + _registry: Registry, 407 + } 408 + 409 + impl RoomActor { 410 + /// Run the event loop. Returns when rx is closed or Shutdown received. 411 + async fn run(mut self) { 412 + while let Some(cmd) = self.rx.recv().await { 413 + self.last_active_tx.send_replace(Utc::now()); 414 + if self.process(cmd).await { 415 + break; 416 + } 417 + } 418 + // On shutdown, abort any active pipeline. 419 + if let Some(abort) = self.pipeline_abort.take() { 420 + let _ = abort.send(true); 421 + } 422 + tracing::info!(room = %self.room_id, "room actor shut down"); 423 + } 424 + 425 + async fn process(&mut self, cmd: RoomCommand) -> bool { 426 + match cmd { 427 + RoomCommand::QueueTrack(meta) => { 428 + tracing::debug!(room = %self.room_id, title = %meta.title, "track queued"); 429 + let playlist = Playlist::new(self.pool.clone(), &self.room_id.0); 430 + if let Err(e) = playlist.push(&meta).await { 431 + tracing::warn!("failed to persist queue item: {e}"); 432 + } 433 + if self.active_track.is_none() { 434 + self.start_next().await; 435 + } 436 + self.publish_state_snapshot().await; 437 + } 438 + RoomCommand::Skip => { 439 + tracing::info!(room = %self.room_id, "skip requested"); 440 + let finished_id = self.active_track.take().map(|t| t.id); 441 + if let Some(abort) = self.pipeline_abort.take() { 442 + let _ = abort.send(true); 443 + } 444 + if let Some(id) = finished_id { 445 + let playlist = Playlist::new(self.pool.clone(), &self.room_id.0); 446 + let _ = playlist.mark_finished(id).await; 447 + } 448 + self.start_next().await; 449 + self.publish_state_snapshot().await; 450 + } 451 + RoomCommand::TrackEnded { item_id } => { 452 + tracing::debug!(room = %self.room_id, item_id, "track ended"); 453 + let is_current = self 454 + .active_track 455 + .as_ref() 456 + .map(|t| t.id == item_id) 457 + .unwrap_or(false); 458 + if is_current { 459 + self.active_track = None; 460 + self.pipeline_abort = None; 461 + let playlist = Playlist::new(self.pool.clone(), &self.room_id.0); 462 + let _ = playlist.mark_finished(item_id).await; 463 + self.start_next().await; 464 + self.publish_state_snapshot().await; 465 + } 466 + } 467 + RoomCommand::Chat { 468 + user_name, 469 + content, 470 + msg_type, 471 + } => { 472 + let created_at = chrono::Utc::now() 473 + .format("%Y-%m-%dT%H:%M:%S%.3fZ") 474 + .to_string(); 475 + let msg = db::NewChatMessage { 476 + room_id: self.room_id.0.clone(), 477 + user_name: user_name.clone(), 478 + content: content.clone(), 479 + msg_type: msg_type.clone(), 480 + created_at: created_at.clone(), 481 + }; 482 + if let Ok(msg_id) = db::chat_insert(&self.pool, &msg).await { 483 + let json = serde_json::json!({ 484 + "user": user_name, 485 + "content": content, 486 + "type": msg_type, 487 + "ts": created_at, 488 + "id": msg_id, 489 + }); 490 + let payload = serde_json::to_vec(&json).unwrap_or_default(); 491 + self.publishers.publish_chat(0, Bytes::from(payload)); 492 + } 493 + // No state snapshot needed for chat. 494 + } 495 + RoomCommand::RegisterClient { resp } => { 496 + let id = self.next_user_id; 497 + self.next_user_id = self.next_user_id.wrapping_add(1); 498 + self.client_count = self.client_count.saturating_add(1); 499 + let _ = resp.send(id); 500 + self.publish_state_snapshot().await; 501 + } 502 + RoomCommand::UnregisterClient => { 503 + self.client_count = self.client_count.saturating_sub(1); 504 + self.publish_state_snapshot().await; 505 + } 506 + RoomCommand::Shutdown => { 507 + tracing::info!(room = %self.room_id, "actor shutdown requested"); 508 + if let Some(abort) = self.pipeline_abort.take() { 509 + let _ = abort.send(true); 510 + } 511 + return true; 512 + } 513 + } 514 + false 515 + } 516 + 517 + async fn start_next(&mut self) { 518 + let playlist = Playlist::new(self.pool.clone(), &self.room_id.0); 519 + let item = match playlist.pop_next().await { 520 + Ok(Some(item)) => item, 521 + Ok(None) => return, 522 + Err(e) => { 523 + tracing::warn!("failed to pop queue: {e}"); 524 + return; 525 + } 526 + }; 527 + tracing::info!(room = %self.room_id, title = %item.title, item_id = item.id, "starting track"); 528 + let (abort_tx, abort_rx) = watch::channel(false); 529 + let info = ActiveTrackInfo::from_item(&item); 530 + self.active_track = Some(info); 531 + self.pipeline_abort = Some(abort_tx); 532 + 533 + let url = item.url.clone(); 534 + let cache = self.cache_dir.clone(); 535 + let publishers = self.publishers.clone(); 536 + let pipeline_tx = self.pipeline_tx.clone(); 537 + let track_item_id = item.id; 538 + let group_id = item.id as u64; 539 + 540 + tokio::spawn(async move { 541 + let _result = media::spawn_pipeline(&url, &cache, publishers, abort_rx, group_id).await; 542 + // Small delay before sending TrackEnded (avoids tight loop, 543 + // gives Skip time to clear state first). 544 + tokio::time::sleep(Duration::from_millis(200)).await; 545 + let _ = pipeline_tx 546 + .send(RoomCommand::TrackEnded { 547 + item_id: track_item_id, 548 + }) 549 + .await; 550 + }); 551 + } 552 + 553 + async fn publish_state_snapshot(&self) { 554 + let current = self.active_track.as_ref().map(|t| TrackState { 555 + id: t.id, 556 + title: t.title.clone(), 557 + url: t.url.clone(), 558 + duration: t.duration.clone(), 559 + started_at: t.started_at_wall, 560 + }); 561 + let client_count = self.client_count; 562 + 563 + let playlist = Playlist::new(self.pool.clone(), &self.room_id.0); 564 + let upcoming = playlist.upcoming().await.unwrap_or_default(); 565 + let history = playlist.history(50).await.unwrap_or_default(); 566 + 567 + let queue: Vec<QueueSummary> = upcoming 568 + .into_iter() 569 + .map(|i| QueueSummary { 570 + id: i.id, 571 + title: i.title, 572 + url: i.url, 573 + duration: i.duration, 574 + thumbnail: i.thumbnail, 575 + }) 576 + .collect(); 577 + 578 + let history_entries: Vec<HistoryEntry> = history 579 + .into_iter() 580 + .map(|i| HistoryEntry { 581 + id: i.id, 582 + title: i.title, 583 + url: i.url, 584 + duration: i.duration, 585 + thumbnail: i.thumbnail, 586 + played_at: i.played_at.unwrap_or_default(), 587 + }) 588 + .collect(); 589 + 590 + tracing::trace!(room = %self.room_id, queue_len = %queue.len(), history_len = %history_entries.len(), clients = %client_count, "state snapshot published"); 591 + 592 + let snapshot = serde_json::json!({ 593 + "current_track": current, 594 + "queue": queue, 595 + "history": history_entries, 596 + "clients": client_count, 597 + }); 598 + let payload = Bytes::from(serde_json::to_vec(&snapshot).unwrap_or_default()); 599 + self.publishers.publish_state(payload); 600 + } 601 + } 602 + 603 + #[cfg(test)] 604 + mod tests { 605 + use super::*; 606 + use sqlx::sqlite::SqlitePoolOptions; 607 + 608 + async fn test_pool() -> Pool<Sqlite> { 609 + let pool = SqlitePoolOptions::new() 610 + .max_connections(1) 611 + .connect(":memory:") 612 + .await 613 + .unwrap(); 614 + db::run_migrations(&pool).await.unwrap(); 615 + pool 616 + } 617 + 618 + #[tokio::test] 619 + async fn test_snowflake_uniqueness() { 620 + let gen_id = Arc::new(SnowflakeIdGen::new(1)); 621 + let mut handles = Vec::new(); 622 + for _ in 0..100 { 623 + let g = gen_id.clone(); 624 + handles.push(tokio::spawn(async move { g.next_id().await })); 625 + } 626 + 627 + let mut ids: Vec<u64> = Vec::with_capacity(100); 628 + for h in handles { 629 + ids.push(h.await.unwrap()); 630 + } 631 + 632 + ids.sort(); 633 + let original_len = ids.len(); 634 + ids.dedup(); 635 + assert_eq!(ids.len(), original_len, "duplicate snowflake IDs"); 636 + assert!( 637 + ids.windows(2).all(|w| w[0] < w[1]), 638 + "IDs not monotonically increasing" 639 + ); 640 + } 641 + 642 + #[tokio::test] 643 + async fn test_create_and_exists() { 644 + let pool = test_pool().await; 645 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 646 + let room = reg.create().await; 647 + assert!(reg.exists(&room.id).await); 648 + } 649 + 650 + #[tokio::test] 651 + async fn test_push_and_queue() { 652 + let pool = test_pool().await; 653 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 654 + let room = reg.create().await; 655 + 656 + let meta_a = TrackMeta { 657 + title: "Track A".into(), 658 + duration: "3:45".into(), 659 + thumbnail: None, 660 + url: "https://example.com/a".into(), 661 + extractor: "test".into(), 662 + }; 663 + let meta_b = TrackMeta { 664 + title: "Track B".into(), 665 + duration: "4:20".into(), 666 + thumbnail: None, 667 + url: "https://example.com/b".into(), 668 + extractor: "test".into(), 669 + }; 670 + 671 + // Push first track — it gets popped and starts playing immediately. 672 + reg.push(&room.id, meta_a.clone()).await; 673 + // Push second track — stays in the upcoming queue. 674 + reg.push(&room.id, meta_b.clone()).await; 675 + 676 + // Give the actor time to process both commands. 677 + tokio::time::sleep(Duration::from_millis(100)).await; 678 + 679 + let queue = reg.queue(&room.id).await; 680 + assert_eq!(queue.len(), 1, "only unplayed track should appear in queue"); 681 + assert_eq!(queue[0].title, "Track B"); 682 + } 683 + 684 + #[tokio::test] 685 + async fn test_remove() { 686 + let pool = test_pool().await; 687 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 688 + let room = reg.create().await; 689 + 690 + reg.remove(&room.id).await; 691 + assert!(!reg.exists(&room.id).await); 692 + } 693 + 694 + #[tokio::test] 695 + async fn test_send_chat() { 696 + let pool = test_pool().await; 697 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 698 + let room = reg.create().await; 699 + 700 + let msg = reg 701 + .send_chat(&room.id, "alice", "hello world", "message") 702 + .await 703 + .unwrap(); 704 + assert_eq!(msg.user_name, "alice"); 705 + assert_eq!(msg.content, "hello world"); 706 + assert_eq!(msg.msg_type, "message"); 707 + assert!(!msg.created_at.is_empty()); 708 + } 709 + 710 + #[tokio::test] 711 + async fn test_send_chat_persistence() { 712 + let pool = test_pool().await; 713 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 714 + let room = reg.create().await; 715 + 716 + let _ = reg 717 + .send_chat(&room.id, "alice", "msg1", "message") 718 + .await 719 + .unwrap(); 720 + let _ = reg 721 + .send_chat(&room.id, "bob", "msg2", "message") 722 + .await 723 + .unwrap(); 724 + 725 + // Give the actor time to process both chat commands. 726 + tokio::time::sleep(Duration::from_millis(100)).await; 727 + 728 + let recent = reg.recent_chat(&room.id, 10).await.unwrap(); 729 + assert_eq!(recent.len(), 2); 730 + // recent_chat returns most recent first. 731 + assert_eq!(recent[0].user_name, "bob"); 732 + assert_eq!(recent[0].content, "msg2"); 733 + assert_eq!(recent[1].user_name, "alice"); 734 + assert_eq!(recent[1].content, "msg1"); 735 + } 736 + 737 + #[tokio::test] 738 + async fn test_send_chat_empty_content() { 739 + let pool = test_pool().await; 740 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 741 + let room = reg.create().await; 742 + 743 + let result = reg.send_chat(&room.id, "alice", "", "message").await; 744 + assert!(result.is_err()); 745 + } 746 + 747 + #[tokio::test] 748 + async fn test_send_chat_long_content() { 749 + let pool = test_pool().await; 750 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 751 + let room = reg.create().await; 752 + 753 + let long = "a".repeat(2001); 754 + let result = reg.send_chat(&room.id, "alice", &long, "message").await; 755 + assert!(result.is_err()); 756 + } 757 + 758 + #[tokio::test] 759 + async fn test_send_chat_long_username() { 760 + let pool = test_pool().await; 761 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 762 + let room = reg.create().await; 763 + 764 + let long = "a".repeat(33); 765 + let result = reg.send_chat(&room.id, &long, "hello", "message").await; 766 + assert!(result.is_err()); 767 + } 768 + 769 + #[tokio::test] 770 + async fn test_register_client() { 771 + let pool = test_pool().await; 772 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 773 + let room = reg.create().await; 774 + 775 + let id1 = reg.register_client(&room.id).await; 776 + assert_eq!(id1, Some(1)); 777 + 778 + let id2 = reg.register_client(&room.id).await; 779 + assert_eq!(id2, Some(2)); 780 + } 781 + 782 + #[tokio::test] 783 + async fn test_register_client_nonexistent_room() { 784 + let pool = test_pool().await; 785 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 786 + let fake_id = RoomId("nonexistent".into()); 787 + assert_eq!(reg.register_client(&fake_id).await, None); 788 + } 789 + 790 + #[tokio::test] 791 + async fn test_unregister_client() { 792 + let pool = test_pool().await; 793 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 794 + let room = reg.create().await; 795 + 796 + // Register and unregister — subsequent register should still work. 797 + reg.register_client(&room.id).await; 798 + reg.unregister_client(&room.id).await; 799 + 800 + let id = reg.register_client(&room.id).await; 801 + assert_eq!(id, Some(2)); // next_user_id is monotonically increasing 802 + } 803 + 804 + #[tokio::test] 805 + async fn test_skip_when_idle_does_not_panic() { 806 + let pool = test_pool().await; 807 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 808 + let room = reg.create().await; 809 + // Skip on an empty, idle room should not panic or deadlock. 810 + reg.skip(&room.id).await; 811 + // If we got here without panic, the test passes. 812 + } 813 + 814 + #[tokio::test] 815 + async fn test_double_skip_does_not_deadlock() { 816 + let pool = test_pool().await; 817 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 818 + let room = reg.create().await; 819 + 820 + let meta = TrackMeta { 821 + title: "A".into(), 822 + duration: "3:45".into(), 823 + thumbnail: None, 824 + url: "https://example.com/a".into(), 825 + extractor: "test".into(), 826 + }; 827 + reg.push(&room.id, meta).await; 828 + tokio::time::sleep(Duration::from_millis(100)).await; 829 + 830 + // Two rapid skips — must not deadlock or corrupt state. 831 + let r1 = reg.clone(); 832 + let rid1 = room.id.clone(); 833 + let h1 = tokio::spawn(async move { r1.skip(&rid1).await }); 834 + let r2 = reg.clone(); 835 + let rid2 = room.id.clone(); 836 + let h2 = tokio::spawn(async move { r2.skip(&rid2).await }); 837 + let _ = tokio::join!(h1, h2); 838 + } 839 + 840 + #[tokio::test] 841 + async fn test_send_chat_returns_id_zero() { 842 + let pool = test_pool().await; 843 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 844 + let room = reg.create().await; 845 + let msg = reg 846 + .send_chat(&room.id, "alice", "hello", "message") 847 + .await 848 + .unwrap(); 849 + // With the actor architecture, send_chat returns id: 0 (fire-and-forget). 850 + assert_eq!(msg.id, 0, "send_chat should return id 0 in actor mode"); 851 + } 852 + 853 + #[tokio::test] 854 + async fn test_sweep_idle_removes_inactive_rooms() { 855 + let pool = test_pool().await; 856 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 857 + let room = reg.create().await; 858 + 859 + // Tiny sleep so the room has a non-zero idle duration. 860 + tokio::time::sleep(Duration::from_millis(1)).await; 861 + 862 + // Immediately sweep with a 0-second timeout. 863 + let swept = reg.sweep_idle(Duration::from_secs(0)).await; 864 + assert!(swept.contains(&room.id), "room should be swept immediately"); 865 + assert!( 866 + !reg.exists(&room.id).await, 867 + "room should no longer exist after sweep" 868 + ); 869 + } 870 + 871 + #[tokio::test] 872 + async fn test_sweep_idle_preserves_active_rooms() { 873 + let pool = test_pool().await; 874 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 875 + let room = reg.create().await; 876 + 877 + // Register a client (triggers state snapshot, updates last_active). 878 + reg.register_client(&room.id).await; 879 + // Wait a tiny bit, then sweep with a timeout slightly longer than the wait. 880 + tokio::time::sleep(Duration::from_millis(50)).await; 881 + let swept = reg.sweep_idle(Duration::from_millis(100)).await; 882 + assert!(!swept.contains(&room.id), "active room should not be swept"); 883 + } 884 + 885 + #[tokio::test] 886 + async fn test_push_starts_playback_when_idle() { 887 + let pool = test_pool().await; 888 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 889 + let room = reg.create().await; 890 + 891 + let meta = TrackMeta { 892 + title: "Test".into(), 893 + duration: "3:45".into(), 894 + thumbnail: None, 895 + url: "https://example.com/t".into(), 896 + extractor: "test".into(), 897 + }; 898 + reg.push(&room.id, meta).await; 899 + 900 + // Give actor time to process. 901 + tokio::time::sleep(Duration::from_millis(100)).await; 902 + 903 + // The track should have been popped from the queue (marked as playing). 904 + let queue = reg.queue(&room.id).await; 905 + assert!( 906 + queue.is_empty(), 907 + "track should be popped when playback starts" 908 + ); 909 + } 910 + 911 + #[tokio::test] 912 + async fn test_push_appends_when_already_playing() { 913 + let pool = test_pool().await; 914 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 915 + let room = reg.create().await; 916 + 917 + // First track starts playing immediately. 918 + reg.push( 919 + &room.id, 920 + TrackMeta { 921 + title: "A".into(), 922 + duration: "3:45".into(), 923 + thumbnail: None, 924 + url: "https://example.com/a".into(), 925 + extractor: "test".into(), 926 + }, 927 + ) 928 + .await; 929 + tokio::time::sleep(Duration::from_millis(100)).await; 930 + 931 + // Second track should stay in the queue. 932 + reg.push( 933 + &room.id, 934 + TrackMeta { 935 + title: "B".into(), 936 + duration: "3:45".into(), 937 + thumbnail: None, 938 + url: "https://example.com/b".into(), 939 + extractor: "test".into(), 940 + }, 941 + ) 942 + .await; 943 + tokio::time::sleep(Duration::from_millis(100)).await; 944 + 945 + let queue = reg.queue(&room.id).await; 946 + assert_eq!(queue.len(), 1, "second track should be queued"); 947 + assert_eq!(queue[0].title, "B"); 948 + 949 + // History should be empty (first track is still "playing", not finished). 950 + let playlist = crate::playlist::Playlist::new(reg.pool.clone(), &room.id.0); 951 + let history = playlist.history(10).await.unwrap(); 952 + assert!( 953 + history.is_empty(), 954 + "no history yet — first track is still playing" 955 + ); 956 + } 957 + 958 + #[tokio::test] 959 + async fn test_skip_when_queue_empty_after_track_ends() { 960 + let pool = test_pool().await; 961 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 962 + let room = reg.create().await; 963 + 964 + // Push a track, let it start playing, then skip. 965 + reg.push( 966 + &room.id, 967 + TrackMeta { 968 + title: "A".into(), 969 + duration: "3:45".into(), 970 + thumbnail: None, 971 + url: "https://example.com/a".into(), 972 + extractor: "test".into(), 973 + }, 974 + ) 975 + .await; 976 + tokio::time::sleep(Duration::from_millis(100)).await; 977 + 978 + reg.skip(&room.id).await; 979 + tokio::time::sleep(Duration::from_millis(100)).await; 980 + 981 + // After skip with empty queue, nothing should be playing and no panic. 982 + let queue = reg.queue(&room.id).await; 983 + assert!( 984 + queue.is_empty(), 985 + "queue should be empty after skip with no next track" 986 + ); 987 + } 988 + 989 + #[tokio::test] 990 + async fn test_double_skip_does_not_corrupt_state() { 991 + let pool = test_pool().await; 992 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 993 + let room = reg.create().await; 994 + 995 + // Push two tracks and play the first. 996 + reg.push( 997 + &room.id, 998 + TrackMeta { 999 + title: "A".into(), 1000 + duration: "3:45".into(), 1001 + thumbnail: None, 1002 + url: "https://example.com/a".into(), 1003 + extractor: "test".into(), 1004 + }, 1005 + ) 1006 + .await; 1007 + tokio::time::sleep(Duration::from_millis(100)).await; 1008 + 1009 + // Push second track. 1010 + reg.push( 1011 + &room.id, 1012 + TrackMeta { 1013 + title: "B".into(), 1014 + duration: "3:45".into(), 1015 + thumbnail: None, 1016 + url: "https://example.com/b".into(), 1017 + extractor: "test".into(), 1018 + }, 1019 + ) 1020 + .await; 1021 + tokio::time::sleep(Duration::from_millis(50)).await; 1022 + 1023 + // Two rapid skips. 1024 + reg.skip(&room.id).await; 1025 + reg.skip(&room.id).await; 1026 + tokio::time::sleep(Duration::from_millis(100)).await; 1027 + 1028 + // After double skip, B should be in history and next should be idle. 1029 + let playlist = crate::playlist::Playlist::new(reg.pool.clone(), &room.id.0); 1030 + let history = playlist.history(10).await.unwrap(); 1031 + // At least one track should be in history (A or B or both). 1032 + assert!( 1033 + !history.is_empty(), 1034 + "at least one track should be in history after double skip" 1035 + ); 1036 + // The later track in history should be the last skipped one. 1037 + assert_eq!(history[0].title, "B", "last skipped track should be B"); 1038 + } 1039 + 1040 + #[tokio::test] 1041 + async fn test_register_client_overflow() { 1042 + let pool = test_pool().await; 1043 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 1044 + let room = reg.create().await; 1045 + 1046 + // Register many clients — overflow should not panic. 1047 + for _ in 0..100 { 1048 + let id = reg.register_client(&room.id).await; 1049 + assert!(id.is_some(), "register_client should return Some(id)"); 1050 + } 1051 + tokio::time::sleep(Duration::from_millis(50)).await; 1052 + 1053 + // Room should still be usable after many registrations. 1054 + let exists = reg.exists(&room.id).await; 1055 + assert!(exists, "room should still exist"); 1056 + } 1057 + 1058 + #[tokio::test] 1059 + async fn test_remove_while_playing_does_not_panic() { 1060 + let pool = test_pool().await; 1061 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 1062 + let room = reg.create().await; 1063 + 1064 + reg.push( 1065 + &room.id, 1066 + TrackMeta { 1067 + title: "A".into(), 1068 + duration: "3:45".into(), 1069 + thumbnail: None, 1070 + url: "https://example.com/a".into(), 1071 + extractor: "test".into(), 1072 + }, 1073 + ) 1074 + .await; 1075 + tokio::time::sleep(Duration::from_millis(100)).await; 1076 + 1077 + // Remove room while track is playing. 1078 + reg.remove(&room.id).await; 1079 + tokio::time::sleep(Duration::from_millis(100)).await; 1080 + 1081 + assert!( 1082 + !reg.exists(&room.id).await, 1083 + "room should not exist after remove" 1084 + ); 1085 + } 1086 + 1087 + #[tokio::test] 1088 + async fn test_sweep_idle_keeps_active_room() { 1089 + let pool = test_pool().await; 1090 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 1091 + let room = reg.create().await; 1092 + 1093 + // Register a client (updates last_active via actor while we await). 1094 + reg.register_client(&room.id).await; 1095 + 1096 + // Immediately sweep with a short timeout — room should NOT be swept because 1097 + // register_client just updated last_active (elapsed time ≪ timeout). 1098 + let swept = reg.sweep_idle(Duration::from_millis(10_000)).await; 1099 + assert!( 1100 + !swept.contains(&room.id), 1101 + "active room should not be swept while clients are connected" 1102 + ); 1103 + } 1104 + 1105 + #[tokio::test] 1106 + async fn test_client_count_tracking() { 1107 + let pool = test_pool().await; 1108 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 1109 + let room = reg.create().await; 1110 + 1111 + // Register 3 clients. 1112 + let id1 = reg.register_client(&room.id).await; 1113 + let id2 = reg.register_client(&room.id).await; 1114 + let id3 = reg.register_client(&room.id).await; 1115 + assert!(id1.is_some() && id2.is_some() && id3.is_some()); 1116 + 1117 + // Unregister one. 1118 + reg.unregister_client(&room.id).await; 1119 + tokio::time::sleep(Duration::from_millis(50)).await; 1120 + 1121 + // Register another — should get a new id. 1122 + let id4 = reg.register_client(&room.id).await; 1123 + assert!(id4.is_some(), "new client should get an id"); 1124 + assert_ne!(id4, id1, "ids should be unique"); 1125 + } 1126 + 1127 + #[tokio::test] 1128 + async fn test_multiple_rooms_dont_interfere() { 1129 + let pool = test_pool().await; 1130 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 1131 + let room_a = reg.create().await; 1132 + let room_b = reg.create().await; 1133 + 1134 + assert_ne!(room_a.id, room_b.id, "room IDs should be unique"); 1135 + assert!(reg.exists(&room_a.id).await); 1136 + assert!(reg.exists(&room_b.id).await); 1137 + 1138 + reg.push( 1139 + &room_a.id, 1140 + TrackMeta { 1141 + title: "A".into(), 1142 + duration: "1:00".into(), 1143 + thumbnail: None, 1144 + url: "https://example.com/a".into(), 1145 + extractor: "test".into(), 1146 + }, 1147 + ) 1148 + .await; 1149 + reg.push( 1150 + &room_b.id, 1151 + TrackMeta { 1152 + title: "B".into(), 1153 + duration: "2:00".into(), 1154 + thumbnail: None, 1155 + url: "https://example.com/b".into(), 1156 + extractor: "test".into(), 1157 + }, 1158 + ) 1159 + .await; 1160 + tokio::time::sleep(Duration::from_millis(100)).await; 1161 + 1162 + let queue_a = reg.queue(&room_a.id).await; 1163 + let queue_b = reg.queue(&room_b.id).await; 1164 + // Each room's first track was popped (played) so queue should be empty. 1165 + assert!( 1166 + queue_a.is_empty(), 1167 + "room A should be empty after first track starts" 1168 + ); 1169 + assert!( 1170 + queue_b.is_empty(), 1171 + "room B should be empty after first track starts" 1172 + ); 1173 + } 1174 + 1175 + #[tokio::test] 1176 + async fn test_skip_on_empty_queue_no_crash() { 1177 + let pool = test_pool().await; 1178 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 1179 + let room = reg.create().await; 1180 + // Skip with no tracks ever queued. 1181 + reg.skip(&room.id).await; 1182 + tokio::time::sleep(Duration::from_millis(50)).await; 1183 + // No crash = pass. 1184 + } 1185 + 1186 + #[tokio::test] 1187 + async fn test_push_after_skip_works() { 1188 + let pool = test_pool().await; 1189 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 1190 + let room = reg.create().await; 1191 + 1192 + // Push and skip. 1193 + reg.push( 1194 + &room.id, 1195 + TrackMeta { 1196 + title: "A".into(), 1197 + duration: "3:45".into(), 1198 + thumbnail: None, 1199 + url: "https://a".into(), 1200 + extractor: "test".into(), 1201 + }, 1202 + ) 1203 + .await; 1204 + tokio::time::sleep(Duration::from_millis(100)).await; 1205 + reg.skip(&room.id).await; 1206 + tokio::time::sleep(Duration::from_millis(100)).await; 1207 + 1208 + // Push another track — should play. 1209 + reg.push( 1210 + &room.id, 1211 + TrackMeta { 1212 + title: "B".into(), 1213 + duration: "3:45".into(), 1214 + thumbnail: None, 1215 + url: "https://b".into(), 1216 + extractor: "test".into(), 1217 + }, 1218 + ) 1219 + .await; 1220 + tokio::time::sleep(Duration::from_millis(100)).await; 1221 + 1222 + // B should be popped from the queue (playing). 1223 + let queue = reg.queue(&room.id).await; 1224 + assert!(queue.is_empty(), "new track should be playing"); 1225 + 1226 + // History should have A (skipped). 1227 + let playlist = crate::playlist::Playlist::new(reg.pool.clone(), &room.id.0); 1228 + let history = playlist.history(10).await.unwrap(); 1229 + assert!(!history.is_empty(), "skipped track should be in history"); 1230 + } 1231 + 1232 + #[tokio::test] 1233 + async fn test_client_count_does_not_underflow() { 1234 + let pool = test_pool().await; 1235 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 1236 + let room = reg.create().await; 1237 + 1238 + // Unregister with no clients registered — should not underflow. 1239 + reg.unregister_client(&room.id).await; 1240 + tokio::time::sleep(Duration::from_millis(50)).await; 1241 + 1242 + // Room should still work. 1243 + assert!(reg.exists(&room.id).await); 1244 + } 1245 + 1246 + #[tokio::test] 1247 + async fn test_multiple_skips_sequential() { 1248 + let pool = test_pool().await; 1249 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox")); 1250 + let room = reg.create().await; 1251 + 1252 + // Push 3 tracks, skip through all. 1253 + for title in ["A", "B", "C"] { 1254 + reg.push( 1255 + &room.id, 1256 + TrackMeta { 1257 + title: title.into(), 1258 + duration: "1:00".into(), 1259 + thumbnail: None, 1260 + url: "https://x".into(), 1261 + extractor: "test".into(), 1262 + }, 1263 + ) 1264 + .await; 1265 + } 1266 + tokio::time::sleep(Duration::from_millis(100)).await; 1267 + 1268 + // Queue should have B, C (A is playing). 1269 + let mut queue = reg.queue(&room.id).await; 1270 + assert_eq!(queue.len(), 2, "B and C should be queued"); 1271 + 1272 + // Skip A → B plays. 1273 + reg.skip(&room.id).await; 1274 + tokio::time::sleep(Duration::from_millis(100)).await; 1275 + queue = reg.queue(&room.id).await; 1276 + assert_eq!(queue.len(), 1, "only C should remain after skipping A"); 1277 + assert_eq!(queue[0].title, "C"); 1278 + 1279 + // Skip B → C plays. 1280 + reg.skip(&room.id).await; 1281 + tokio::time::sleep(Duration::from_millis(100)).await; 1282 + queue = reg.queue(&room.id).await; 1283 + assert!(queue.is_empty(), "nothing should remain after skipping B"); 1284 + 1285 + // Skip C → idle. 1286 + reg.skip(&room.id).await; 1287 + tokio::time::sleep(Duration::from_millis(100)).await; 1288 + // No crash = pass. 1289 + } 1290 + }
+870
src/transport.rs
··· 1 + //! MoQ WebSocket transport layer (draft-ietf-moq-transport-18 subset). 2 + //! 3 + //! Provides varint encoding/decoding, MoQ message types, track publishers 4 + //! with broadcast channels, and a WebSocket session handler. 5 + 6 + use axum::extract::ws::{CloseFrame, Message, WebSocket}; 7 + use bytes::Bytes; 8 + use futures_util::{SinkExt, StreamExt}; 9 + use std::time::Duration; 10 + use tokio::sync::{broadcast, mpsc}; 11 + 12 + use crate::room; 13 + use crate::types::{RoomCommand, RoomId, TrackId, TrackPublishers}; 14 + 15 + pub(crate) fn encode_varint(value: u64) -> Vec<u8> { 16 + if value < 64 { 17 + vec![value as u8] 18 + } else if value < 16_384 { 19 + let encoded = (value as u16) | 0x4000; 20 + vec![(encoded >> 8) as u8, encoded as u8] 21 + } else if value < 1_073_741_824 { 22 + let encoded = (value as u32) | 0x8000_0000; 23 + vec![ 24 + (encoded >> 24) as u8, 25 + (encoded >> 16) as u8, 26 + (encoded >> 8) as u8, 27 + encoded as u8, 28 + ] 29 + } else { 30 + let encoded = value | 0xC000_0000_0000_0000; 31 + vec![ 32 + (encoded >> 56) as u8, 33 + (encoded >> 48) as u8, 34 + (encoded >> 40) as u8, 35 + (encoded >> 32) as u8, 36 + (encoded >> 24) as u8, 37 + (encoded >> 16) as u8, 38 + (encoded >> 8) as u8, 39 + encoded as u8, 40 + ] 41 + } 42 + } 43 + 44 + pub(crate) fn decode_varint(buf: &[u8]) -> Option<(u64, usize)> { 45 + let first = *buf.first()?; 46 + match first >> 6 { 47 + 0 => Some((u64::from(first & 0x3F), 1)), 48 + 1 => { 49 + if buf.len() < 2 { 50 + return None; 51 + } 52 + let value = u64::from(first & 0x3F) << 8 | u64::from(buf[1]); 53 + Some((value, 2)) 54 + } 55 + 2 => { 56 + if buf.len() < 4 { 57 + return None; 58 + } 59 + let value = u64::from(first & 0x3F) << 24 60 + | u64::from(buf[1]) << 16 61 + | u64::from(buf[2]) << 8 62 + | u64::from(buf[3]); 63 + Some((value, 4)) 64 + } 65 + 3 => { 66 + if buf.len() < 8 { 67 + return None; 68 + } 69 + let value = u64::from(first & 0x3F) << 56 70 + | u64::from(buf[1]) << 48 71 + | u64::from(buf[2]) << 40 72 + | u64::from(buf[3]) << 32 73 + | u64::from(buf[4]) << 24 74 + | u64::from(buf[5]) << 16 75 + | u64::from(buf[6]) << 8 76 + | u64::from(buf[7]); 77 + Some((value, 8)) 78 + } 79 + _ => unreachable!(), 80 + } 81 + } 82 + 83 + fn encode_string(s: &str) -> Vec<u8> { 84 + let mut buf = encode_varint(s.len() as u64); 85 + buf.extend_from_slice(s.as_bytes()); 86 + buf 87 + } 88 + 89 + fn decode_string(buf: &[u8]) -> Option<(String, usize)> { 90 + let (len, mut offset) = decode_varint(buf)?; 91 + let end = offset.checked_add(len as usize)?; 92 + if end > buf.len() { 93 + return None; 94 + } 95 + let s = std::str::from_utf8(&buf[offset..end]).ok()?.to_string(); 96 + offset = end; 97 + Some((s, offset)) 98 + } 99 + 100 + /// MoQ message types (draft-ietf-moq-transport-18 subset). 101 + #[derive(Debug, Clone, PartialEq)] 102 + pub(crate) enum MoqMessage { 103 + Object { 104 + track_id: u64, 105 + group_id: u64, 106 + object_id: u64, 107 + payload: Bytes, 108 + }, 109 + Subscribe { 110 + namespace: String, 111 + track: String, 112 + }, 113 + SubscribeOk { 114 + namespace: String, 115 + track: String, 116 + }, 117 + Announce { 118 + namespace: String, 119 + }, 120 + AnnounceOk { 121 + namespace: String, 122 + }, 123 + SubscribeRst { 124 + namespace: String, 125 + track: String, 126 + reason: String, 127 + }, 128 + } 129 + 130 + pub(crate) fn encode_message(msg: &MoqMessage) -> Bytes { 131 + let mut buf = Vec::new(); 132 + match msg { 133 + MoqMessage::Object { 134 + track_id, 135 + group_id, 136 + object_id, 137 + payload, 138 + } => { 139 + buf.extend(encode_varint(0x00)); 140 + buf.extend(encode_varint(*track_id)); 141 + buf.extend(encode_varint(*group_id)); 142 + buf.extend(encode_varint(*object_id)); 143 + buf.extend_from_slice(payload); 144 + } 145 + MoqMessage::Subscribe { namespace, track } => { 146 + buf.extend(encode_varint(0x01)); 147 + buf.extend(encode_string(namespace)); 148 + buf.extend(encode_string(track)); 149 + } 150 + MoqMessage::SubscribeOk { namespace, track } => { 151 + buf.extend(encode_varint(0x02)); 152 + buf.extend(encode_string(namespace)); 153 + buf.extend(encode_string(track)); 154 + } 155 + MoqMessage::Announce { namespace } => { 156 + buf.extend(encode_varint(0x03)); 157 + buf.extend(encode_string(namespace)); 158 + } 159 + MoqMessage::AnnounceOk { namespace } => { 160 + buf.extend(encode_varint(0x04)); 161 + buf.extend(encode_string(namespace)); 162 + } 163 + MoqMessage::SubscribeRst { 164 + namespace, 165 + track, 166 + reason, 167 + } => { 168 + buf.extend(encode_varint(0x06)); 169 + buf.extend(encode_string(namespace)); 170 + buf.extend(encode_string(track)); 171 + buf.extend(encode_string(reason)); 172 + } 173 + } 174 + Bytes::from(buf) 175 + } 176 + 177 + pub(crate) fn decode_message(buf: &[u8]) -> Option<(MoqMessage, usize)> { 178 + let (msg_type, mut offset) = decode_varint(buf)?; 179 + match msg_type { 180 + 0x00 => { 181 + // OBJECT 182 + let (track_id, n) = decode_varint(&buf[offset..])?; 183 + offset += n; 184 + let (group_id, n) = decode_varint(&buf[offset..])?; 185 + offset += n; 186 + let (object_id, n) = decode_varint(&buf[offset..])?; 187 + offset += n; 188 + let payload = Bytes::copy_from_slice(&buf[offset..]); 189 + Some(( 190 + MoqMessage::Object { 191 + track_id, 192 + group_id, 193 + object_id, 194 + payload, 195 + }, 196 + buf.len(), 197 + )) 198 + } 199 + 0x01 => { 200 + // SUBSCRIBE 201 + let (namespace, n) = decode_string(&buf[offset..])?; 202 + offset += n; 203 + let (track, n) = decode_string(&buf[offset..])?; 204 + offset += n; 205 + Some((MoqMessage::Subscribe { namespace, track }, offset)) 206 + } 207 + 0x02 => { 208 + // SUBSCRIBE_OK 209 + let (namespace, n) = decode_string(&buf[offset..])?; 210 + offset += n; 211 + let (track, n) = decode_string(&buf[offset..])?; 212 + offset += n; 213 + Some((MoqMessage::SubscribeOk { namespace, track }, offset)) 214 + } 215 + 0x03 => { 216 + // ANNOUNCE 217 + let (namespace, n) = decode_string(&buf[offset..])?; 218 + offset += n; 219 + Some((MoqMessage::Announce { namespace }, offset)) 220 + } 221 + 0x04 => { 222 + // ANNOUNCE_OK 223 + let (namespace, n) = decode_string(&buf[offset..])?; 224 + offset += n; 225 + Some((MoqMessage::AnnounceOk { namespace }, offset)) 226 + } 227 + 0x06 => { 228 + // SUBSCRIBE_RST 229 + let (namespace, n) = decode_string(&buf[offset..])?; 230 + offset += n; 231 + let (track, n) = decode_string(&buf[offset..])?; 232 + offset += n; 233 + let (reason, n) = decode_string(&buf[offset..])?; 234 + offset += n; 235 + Some(( 236 + MoqMessage::SubscribeRst { 237 + namespace, 238 + track, 239 + reason, 240 + }, 241 + offset, 242 + )) 243 + } 244 + _ => None, 245 + } 246 + } 247 + 248 + pub(crate) fn room_namespace(room_id: &str) -> String { 249 + format!("moqbox/room/{room_id}") 250 + } 251 + 252 + /// Handle a WebSocket upgrade and run the MoQ session lifecycle. 253 + /// 254 + /// 1. Sends `ANNOUNCE` for the room namespace. 255 + /// 2. Loops, reading MoQ messages from the client. 256 + /// 3. On `SUBSCRIBE`, subscribes to the corresponding broadcast receiver 257 + /// and spawns a forward task that relays objects to the WebSocket. 258 + /// 4. On `OBJECT` with track ID 2 (chat), forwards to the chat publisher. 259 + /// 5. On close, cancels all forward tasks. 260 + pub(crate) async fn handle_ws_session( 261 + mut ws: WebSocket, 262 + room_id: RoomId, 263 + registry: &room::Registry, 264 + cmd_tx: tokio::sync::mpsc::Sender<RoomCommand>, 265 + publishers: TrackPublishers, 266 + user_name: String, 267 + ) { 268 + // Validate username before processing any messages. 269 + if user_name.is_empty() || user_name.len() > 32 || user_name.chars().any(|c| c.is_control()) { 270 + let _ = ws 271 + .send(Message::Close(Some(CloseFrame { 272 + code: axum::extract::ws::close_code::POLICY, 273 + reason: "invalid username".into(), 274 + }))) 275 + .await; 276 + return; 277 + } 278 + 279 + let (mut ws_tx, mut ws_rx) = ws.split(); 280 + 281 + // 1. Send ANNOUNCE for this room. 282 + let announce = encode_message(&MoqMessage::Announce { 283 + namespace: room_namespace(&room_id.0), 284 + }); 285 + if ws_tx.send(Message::Binary(announce)).await.is_err() { 286 + return; 287 + } 288 + 289 + // Register this client with the room actor. 290 + let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); 291 + let _ = cmd_tx 292 + .send(RoomCommand::RegisterClient { resp: resp_tx }) 293 + .await; 294 + let _user_id: Option<u64> = tokio::time::timeout(Duration::from_secs(5), resp_rx) 295 + .await 296 + .ok() 297 + .and_then(|r| r.ok()); 298 + 299 + // Channel that broadcast forward tasks use to send objects to the WS writer. 300 + // Unbounded to avoid deadlock between subscribe processing and the WS send loop. 301 + let (object_tx, mut object_rx) = mpsc::unbounded_channel::<Bytes>(); 302 + 303 + // Handles of spawned forward tasks, cancelled on disconnect. 304 + let mut task_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new(); 305 + 306 + loop { 307 + tokio::select! { 308 + msg = ws_rx.next() => { 309 + match msg { 310 + Some(Ok(Message::Binary(data))) => { 311 + if let Some((moq_msg, _consumed)) = decode_message(&data) { 312 + handle_moq_message( 313 + moq_msg, 314 + &room_id, 315 + registry, 316 + &cmd_tx, 317 + &publishers, 318 + &user_name, 319 + &mut ws_tx, 320 + &object_tx, 321 + &mut task_handles, 322 + ).await; 323 + } 324 + } 325 + Some(Ok(Message::Close(_))) => break, 326 + Some(Err(e)) => { 327 + tracing::debug!("ws error: {e}"); 328 + break; 329 + } 330 + None => break, 331 + // Ignore Ping / Pong / Text frames. 332 + _ => {} 333 + } 334 + } 335 + 336 + // Forward objects from broadcast receivers to the WebSocket. 337 + obj_bytes = object_rx.recv() => { 338 + match obj_bytes { 339 + Some(bytes) => { 340 + if ws_tx.send(Message::Binary(bytes)).await.is_err() { 341 + break; 342 + } 343 + } 344 + // All senders dropped — no more objects will arrive. 345 + None => break, 346 + } 347 + } 348 + } 349 + } 350 + 351 + // Unregister this client with the room actor. 352 + let _ = cmd_tx.send(RoomCommand::UnregisterClient).await; 353 + 354 + // Cancel all forward tasks on disconnect. 355 + for handle in task_handles { 356 + handle.abort(); 357 + } 358 + } 359 + 360 + async fn handle_moq_message( 361 + msg: MoqMessage, 362 + room_id: &RoomId, 363 + registry: &room::Registry, 364 + cmd_tx: &tokio::sync::mpsc::Sender<RoomCommand>, 365 + publishers: &TrackPublishers, 366 + user_name: &str, 367 + ws_tx: &mut (impl futures_util::Sink<Message, Error = axum::Error> + Unpin), 368 + object_tx: &tokio::sync::mpsc::UnboundedSender<Bytes>, 369 + task_handles: &mut Vec<tokio::task::JoinHandle<()>>, 370 + ) { 371 + match msg { 372 + MoqMessage::Subscribe { namespace, track } => { 373 + let track_id = match TrackId::from_name(&track) { 374 + Some(id) => id, 375 + None => { 376 + // Unknown track — send SUBSCRIBE_RST. 377 + let rst = encode_message(&MoqMessage::SubscribeRst { 378 + namespace, 379 + track, 380 + reason: "unknown track".into(), 381 + }); 382 + let _ = ws_tx.send(Message::Binary(rst)).await; 383 + return; 384 + } 385 + }; 386 + 387 + // Send SUBSCRIBE_OK first. 388 + let ok = encode_message(&MoqMessage::SubscribeOk { 389 + namespace: namespace.clone(), 390 + track: track.clone(), 391 + }); 392 + if ws_tx.send(Message::Binary(ok)).await.is_err() { 393 + return; 394 + } 395 + 396 + match track_id { 397 + TrackId::Chat => { 398 + // Send recent chat history, then subscribe to live broadcast. 399 + if let Ok(messages) = registry.recent_chat(room_id, 50).await { 400 + for msg in messages.iter().rev() { 401 + let json = serde_json::json!({ 402 + "user": msg.user_name, 403 + "content": msg.content, 404 + "type": msg.msg_type, 405 + "ts": msg.created_at, 406 + "id": msg.id, 407 + }); 408 + let payload = serde_json::to_vec(&json).unwrap_or_default(); 409 + let obj = encode_message(&MoqMessage::Object { 410 + track_id: TrackId::Chat as u64, 411 + group_id: 0, 412 + object_id: msg.id as u64, 413 + payload: Bytes::from(payload), 414 + }); 415 + if object_tx.send(obj).is_err() { 416 + return; 417 + } 418 + } 419 + } 420 + 421 + // Subscribe to live chat broadcast. 422 + let rx = publishers.chat.subscribe(); 423 + let tx = object_tx.clone(); 424 + task_handles.push(tokio::spawn(async move { 425 + let mut rx = rx; 426 + loop { 427 + match rx.recv().await { 428 + Ok(obj) => { 429 + let msg = encode_message(&MoqMessage::Object { 430 + track_id: obj.track_id as u64, 431 + group_id: obj.group_id, 432 + object_id: obj.object_id, 433 + payload: obj.payload, 434 + }); 435 + if tx.send(msg).is_err() { 436 + break; 437 + } 438 + } 439 + Err(broadcast::error::RecvError::Lagged(_)) => continue, 440 + Err(broadcast::error::RecvError::Closed) => break, 441 + } 442 + } 443 + })); 444 + } 445 + TrackId::State => { 446 + // State uses watch (latest-only). Send current value 447 + // immediately, then forward changes. 448 + let initial = publishers.state.borrow().clone(); 449 + let obj = encode_message(&MoqMessage::Object { 450 + track_id: TrackId::State as u64, 451 + group_id: 0, 452 + object_id: initial.seq, 453 + payload: initial.payload, 454 + }); 455 + if object_tx.send(obj).is_err() { 456 + return; 457 + } 458 + 459 + let mut state_rx = publishers.state.subscribe(); 460 + let tx = object_tx.clone(); 461 + task_handles.push(tokio::spawn(async move { 462 + loop { 463 + if state_rx.changed().await.is_err() { 464 + break; 465 + } 466 + let val = state_rx.borrow().clone(); 467 + let obj = encode_message(&MoqMessage::Object { 468 + track_id: TrackId::State as u64, 469 + group_id: 0, 470 + object_id: val.seq, 471 + payload: val.payload, 472 + }); 473 + if tx.send(obj).is_err() { 474 + break; 475 + } 476 + } 477 + })); 478 + } 479 + TrackId::Video => { 480 + // Send cached init (or wait for it) and forward live broadcast 481 + // from the same spawned task to guarantee ordering for late joiners. 482 + let init_rx = publishers.video_init_waiter(); 483 + let cached = publishers.get_init_segment(); // snapshot before spawn 484 + let tx = object_tx.clone(); 485 + let publishers_for_task = publishers.clone(); 486 + let rx = publishers.video.subscribe(); 487 + task_handles.push(tokio::spawn(async move { 488 + // 1. Ensure init segment is available (wait if pipeline 489 + // hasn't produced it yet). 490 + let init = if let Some(cached) = cached { 491 + tracing::debug!("forward task: using cached init segment"); 492 + Some(cached) 493 + } else { 494 + tracing::debug!( 495 + "forward task: no cached init, waiting for watch signal" 496 + ); 497 + let mut init_rx = init_rx; 498 + let _ = init_rx.changed().await; 499 + let result = publishers_for_task.get_init_segment(); 500 + if result.is_some() { 501 + tracing::debug!("forward task: received init segment via watch"); 502 + } else { 503 + tracing::warn!("forward task: watch fired but no init available"); 504 + } 505 + result 506 + }; 507 + 508 + if let Some((group_id, init)) = init { 509 + tracing::debug!( 510 + group_id, 511 + size = init.len(), 512 + "forward task: sending init segment" 513 + ); 514 + let obj = encode_message(&MoqMessage::Object { 515 + track_id: TrackId::Video as u64, 516 + group_id, 517 + object_id: 0, 518 + payload: init, 519 + }); 520 + if tx.send(obj).is_err() { 521 + return; 522 + } 523 + } else { 524 + // No init and never got one — nothing to play. 525 + tracing::warn!("forward task: no init segment available, aborting"); 526 + return; 527 + } 528 + 529 + // 2. Catch up to live edge: discard any messages that were 530 + // already in the broadcast buffer before subscription. 531 + // Without this, a late subscriber would replay all prior 532 + // segments from the oldest in the buffer. 533 + let mut rx = rx; 534 + loop { 535 + match rx.try_recv() { 536 + Ok(_) => continue, 537 + Err(broadcast::error::TryRecvError::Empty) => break, 538 + Err(broadcast::error::TryRecvError::Closed) => return, 539 + Err(broadcast::error::TryRecvError::Lagged(_)) => break, 540 + } 541 + } 542 + 543 + // 3. Forward live broadcast messages. 544 + tracing::debug!("forward task: entering broadcast loop"); 545 + loop { 546 + match rx.recv().await { 547 + Ok(obj) => { 548 + let msg = encode_message(&MoqMessage::Object { 549 + track_id: obj.track_id as u64, 550 + group_id: obj.group_id, 551 + object_id: obj.object_id, 552 + payload: obj.payload, 553 + }); 554 + if tx.send(msg).is_err() { 555 + break; 556 + } 557 + } 558 + Err(broadcast::error::RecvError::Lagged(n)) => { 559 + tracing::debug!("forward task: video broadcast lagged by {n}"); 560 + } 561 + Err(broadcast::error::RecvError::Closed) => break, 562 + } 563 + } 564 + })); 565 + } 566 + TrackId::Audio => { 567 + let rx = publishers.audio.subscribe(); 568 + let tx = object_tx.clone(); 569 + task_handles.push(tokio::spawn(async move { 570 + let mut rx = rx; 571 + // Catch up to live edge — discard prior messages. 572 + loop { 573 + match rx.try_recv() { 574 + Ok(_) => continue, 575 + Err(broadcast::error::TryRecvError::Empty) => break, 576 + Err(broadcast::error::TryRecvError::Closed) => return, 577 + Err(broadcast::error::TryRecvError::Lagged(_)) => break, 578 + } 579 + } 580 + loop { 581 + match rx.recv().await { 582 + Ok(obj) => { 583 + let msg = encode_message(&MoqMessage::Object { 584 + track_id: obj.track_id as u64, 585 + group_id: obj.group_id, 586 + object_id: obj.object_id, 587 + payload: obj.payload, 588 + }); 589 + if tx.send(msg).is_err() { 590 + break; 591 + } 592 + } 593 + Err(broadcast::error::RecvError::Lagged(_)) => { 594 + // Live media — skip missed objects. 595 + continue; 596 + } 597 + Err(broadcast::error::RecvError::Closed) => break, 598 + } 599 + } 600 + })); 601 + } 602 + } 603 + } 604 + 605 + MoqMessage::Object { 606 + track_id, 607 + group_id: _, 608 + object_id: _, 609 + payload, 610 + } => { 611 + // Incoming chat message from client: persist and rebroadcast. 612 + if track_id == TrackId::Chat as u64 { 613 + let json: serde_json::Value = 614 + serde_json::from_slice(&payload).unwrap_or(serde_json::Value::Null); 615 + let content = json.get("content").and_then(|v| v.as_str()).unwrap_or(""); 616 + 617 + if !content.is_empty() { 618 + let _ = cmd_tx 619 + .send(RoomCommand::Chat { 620 + user_name: user_name.to_string(), 621 + content: content.to_string(), 622 + msg_type: "message".to_string(), 623 + }) 624 + .await; 625 + } 626 + } 627 + } 628 + 629 + // Server-initiated messages received from client are unexpected. 630 + MoqMessage::Announce { .. } 631 + | MoqMessage::AnnounceOk { .. } 632 + | MoqMessage::SubscribeOk { .. } 633 + | MoqMessage::SubscribeRst { .. } => { 634 + tracing::trace!("unexpected client message: {msg:?}"); 635 + } 636 + } 637 + } 638 + 639 + #[cfg(test)] 640 + mod tests { 641 + use super::*; 642 + 643 + #[test] 644 + fn test_varint_roundtrip() { 645 + let cases = [ 646 + 0u64, 647 + 1, 648 + 63, 649 + 64, 650 + 16383, 651 + 16384, 652 + 1_073_741_823, 653 + 1_073_741_824, 654 + 4_611_686_018_427_387_903, 655 + ]; 656 + for &v in &cases { 657 + let encoded = encode_varint(v); 658 + let (decoded, consumed) = decode_varint(&encoded).expect("decode should succeed"); 659 + assert_eq!(decoded, v, "varint roundtrip failed for {v}"); 660 + assert_eq!( 661 + consumed, 662 + encoded.len(), 663 + "wrong byte count for {v}: expected {}, got {}", 664 + encoded.len(), 665 + consumed 666 + ); 667 + } 668 + } 669 + 670 + #[test] 671 + fn test_varint_decode_empty() { 672 + assert!(decode_varint(b"").is_none()); 673 + } 674 + 675 + #[test] 676 + fn test_varint_decode_truncated() { 677 + // A 2-byte varint needs 2 bytes; give only 1. 678 + assert!(decode_varint(&[0x40]).is_none()); 679 + // A 4-byte varint needs 4 bytes; give only 2. 680 + assert!(decode_varint(&[0x80, 0x00]).is_none()); 681 + // An 8-byte varint needs 8 bytes; give only 4. 682 + assert!(decode_varint(&[0xC0, 0x00, 0x00, 0x00]).is_none()); 683 + } 684 + 685 + #[test] 686 + fn test_message_announce_roundtrip() { 687 + let msg = MoqMessage::Announce { 688 + namespace: "moqbox/room/test".into(), 689 + }; 690 + let wire = encode_message(&msg); 691 + let (decoded, consumed) = decode_message(&wire).expect("decode should succeed"); 692 + assert_eq!(consumed, wire.len()); 693 + assert_eq!(decoded, msg, "Announce roundtrip failed"); 694 + } 695 + 696 + #[test] 697 + fn test_message_announce_ok_roundtrip() { 698 + let msg = MoqMessage::AnnounceOk { 699 + namespace: "moqbox/room/test".into(), 700 + }; 701 + let wire = encode_message(&msg); 702 + let (decoded, _) = decode_message(&wire).expect("decode should succeed"); 703 + assert_eq!(decoded, msg); 704 + } 705 + 706 + #[test] 707 + fn test_message_subscribe_roundtrip() { 708 + let msg = MoqMessage::Subscribe { 709 + namespace: "moqbox/room/test".into(), 710 + track: "audio".into(), 711 + }; 712 + let wire = encode_message(&msg); 713 + let (decoded, _) = decode_message(&wire).expect("decode should succeed"); 714 + assert_eq!(decoded, msg); 715 + } 716 + 717 + #[test] 718 + fn test_message_subscribe_ok_roundtrip() { 719 + let msg = MoqMessage::SubscribeOk { 720 + namespace: "moqbox/room/test".into(), 721 + track: "video".into(), 722 + }; 723 + let wire = encode_message(&msg); 724 + let (decoded, _) = decode_message(&wire).expect("decode should succeed"); 725 + assert_eq!(decoded, msg); 726 + } 727 + 728 + #[test] 729 + fn test_message_subscribe_rst_roundtrip() { 730 + let msg = MoqMessage::SubscribeRst { 731 + namespace: "moqbox/room/test".into(), 732 + track: "chat".into(), 733 + reason: "forbidden".into(), 734 + }; 735 + let wire = encode_message(&msg); 736 + let (decoded, _) = decode_message(&wire).expect("decode should succeed"); 737 + assert_eq!(decoded, msg); 738 + } 739 + 740 + #[test] 741 + fn test_message_object_roundtrip() { 742 + let msg = MoqMessage::Object { 743 + track_id: 0, 744 + group_id: 1, 745 + object_id: 42, 746 + payload: Bytes::from("hello moq"), 747 + }; 748 + let wire = encode_message(&msg); 749 + let (decoded, consumed) = decode_message(&wire).expect("decode should succeed"); 750 + assert_eq!(consumed, wire.len()); 751 + assert_eq!(decoded, msg); 752 + } 753 + 754 + #[test] 755 + fn test_message_empty_object() { 756 + let msg = MoqMessage::Object { 757 + track_id: 2, 758 + group_id: 0, 759 + object_id: 0, 760 + payload: Bytes::new(), 761 + }; 762 + let wire = encode_message(&msg); 763 + let (decoded, _) = decode_message(&wire).expect("decode should succeed"); 764 + assert_eq!(decoded, msg); 765 + } 766 + 767 + #[test] 768 + fn test_decode_unknown_type() { 769 + // Message type 0xFF is not defined. 770 + let buf = encode_varint(0xFF); 771 + assert!(decode_message(&buf).is_none()); 772 + } 773 + 774 + #[test] 775 + fn test_decode_empty_buffer() { 776 + assert!(decode_message(b"").is_none()); 777 + } 778 + 779 + #[test] 780 + fn test_room_namespace() { 781 + assert_eq!(room_namespace("abc"), "moqbox/room/abc"); 782 + assert_eq!(room_namespace("12345"), "moqbox/room/12345"); 783 + assert_eq!(room_namespace(""), "moqbox/room/"); 784 + } 785 + 786 + #[test] 787 + fn test_track_id_from_name() { 788 + assert_eq!(TrackId::from_name("audio"), Some(TrackId::Audio)); 789 + assert_eq!(TrackId::from_name("video"), Some(TrackId::Video)); 790 + assert_eq!(TrackId::from_name("chat"), Some(TrackId::Chat)); 791 + assert_eq!(TrackId::from_name("state"), Some(TrackId::State)); 792 + assert_eq!(TrackId::from_name("invalid"), None); 793 + assert_eq!(TrackId::from_name(""), None); 794 + } 795 + 796 + #[tokio::test] 797 + async fn test_publishers_broadcast_video() { 798 + let publishers = TrackPublishers::new(); 799 + let mut rx = publishers.video.subscribe(); 800 + 801 + publishers.publish_video(5, 99, Bytes::from("video-frame")); 802 + 803 + let obj = rx.recv().await.expect("should receive video object"); 804 + assert_eq!(obj.track_id, TrackId::Video); 805 + assert_eq!(obj.payload, Bytes::from("video-frame")); 806 + } 807 + 808 + #[tokio::test] 809 + async fn test_publishers_broadcast_chat() { 810 + let publishers = TrackPublishers::new(); 811 + let mut rx = publishers.chat.subscribe(); 812 + 813 + publishers.publish_chat(0, Bytes::from("hello")); 814 + 815 + let obj = rx.recv().await.expect("should receive chat object"); 816 + assert_eq!(obj.track_id, TrackId::Chat); 817 + assert_eq!(obj.object_id, 0); 818 + assert_eq!(obj.payload, Bytes::from("hello")); 819 + } 820 + 821 + #[tokio::test] 822 + async fn test_publishers_chat_auto_increment() { 823 + let publishers = TrackPublishers::new(); 824 + let mut rx = publishers.chat.subscribe(); 825 + 826 + publishers.publish_chat(0, Bytes::from("first")); 827 + publishers.publish_chat(0, Bytes::from("second")); 828 + publishers.publish_chat(0, Bytes::from("third")); 829 + 830 + let obj1 = rx.recv().await.expect("msg 1"); 831 + assert_eq!(obj1.object_id, 0); 832 + assert_eq!(obj1.payload, Bytes::from("first")); 833 + 834 + let obj2 = rx.recv().await.expect("msg 2"); 835 + assert_eq!(obj2.object_id, 1); 836 + assert_eq!(obj2.payload, Bytes::from("second")); 837 + 838 + let obj3 = rx.recv().await.expect("msg 3"); 839 + assert_eq!(obj3.object_id, 2); 840 + assert_eq!(obj3.payload, Bytes::from("third")); 841 + } 842 + 843 + #[tokio::test] 844 + async fn test_publishers_state() { 845 + let publishers = TrackPublishers::new(); 846 + let mut rx = publishers.state.subscribe(); 847 + 848 + publishers.publish_state(Bytes::from("playing")); 849 + 850 + // Watch receiver needs changed() to observe the update. 851 + rx.changed().await.expect("state should change"); 852 + let val = rx.borrow().clone(); 853 + assert_eq!(val.payload, Bytes::from("playing")); 854 + assert_eq!(val.seq, 1); // started at 0, now 1 855 + } 856 + 857 + #[tokio::test] 858 + async fn test_publishers_multiple_subscribers() { 859 + let publishers = TrackPublishers::new(); 860 + let mut rx1 = publishers.video.subscribe(); 861 + let mut rx2 = publishers.video.subscribe(); 862 + 863 + publishers.publish_video(0, 0, Bytes::from("fanout")); 864 + 865 + let obj1 = rx1.recv().await.expect("subscriber 1"); 866 + let obj2 = rx2.recv().await.expect("subscriber 2"); 867 + assert_eq!(obj1.payload, Bytes::from("fanout")); 868 + assert_eq!(obj2.payload, Bytes::from("fanout")); 869 + } 870 + }
+335
src/types.rs
··· 1 + //! Shared types used across moqbox modules. 2 + //! 3 + //! Consolidates room state types, MoQ transport types, and track 4 + //! publishing infrastructure into a single module to avoid circular 5 + //! dependencies and keep the module graph acyclic. 6 + 7 + use std::fmt; 8 + use std::sync::atomic::{AtomicU64, Ordering}; 9 + use std::sync::{Arc, Mutex}; 10 + 11 + use bytes::Bytes; 12 + use chrono::{DateTime, Utc}; 13 + use serde::Serialize; 14 + use tokio::sync::{broadcast, mpsc, oneshot, watch}; 15 + 16 + /// Opaque room identifier, human-readable and URL-safe. 17 + #[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize)] 18 + pub(crate) struct RoomId(pub String); 19 + 20 + impl fmt::Display for RoomId { 21 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 22 + self.0.fmt(f) 23 + } 24 + } 25 + 26 + /// Metadata for a single queued track, extracted by yt-dlp. 27 + #[derive(Debug, Clone, Serialize)] 28 + pub(crate) struct TrackMeta { 29 + pub title: String, 30 + pub duration: String, 31 + pub thumbnail: Option<String>, 32 + pub url: String, 33 + pub extractor: String, 34 + } 35 + 36 + /// Snapshot of the currently playing track for state publishing. 37 + #[derive(Debug, Clone, Serialize)] 38 + pub(crate) struct TrackState { 39 + pub(crate) id: i64, 40 + pub(crate) title: String, 41 + pub(crate) url: String, 42 + pub(crate) duration: String, 43 + pub(crate) started_at: i64, 44 + } 45 + 46 + /// Summary of a queued track for state publishing. 47 + #[derive(Debug, Clone, Serialize)] 48 + pub(crate) struct QueueSummary { 49 + pub(crate) id: i64, 50 + pub(crate) title: String, 51 + pub(crate) url: String, 52 + pub(crate) duration: String, 53 + pub(crate) thumbnail: Option<String>, 54 + } 55 + 56 + #[derive(Debug, Clone, Serialize)] 57 + pub(crate) struct HistoryEntry { 58 + pub(crate) id: i64, 59 + pub(crate) title: String, 60 + pub(crate) url: String, 61 + pub(crate) duration: String, 62 + pub(crate) thumbnail: Option<String>, 63 + pub(crate) played_at: String, 64 + } 65 + 66 + /// A chat message returned from send/recent operations. 67 + #[derive(Debug, Clone, Serialize)] 68 + pub(crate) struct ChatMessage { 69 + pub(crate) id: i64, 70 + pub(crate) user_name: String, 71 + pub(crate) content: String, 72 + pub(crate) msg_type: String, 73 + pub(crate) created_at: String, 74 + } 75 + 76 + /// Logical track IDs within a MoQ room. 77 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 78 + pub(crate) enum TrackId { 79 + Audio = 0, 80 + Video = 1, 81 + Chat = 2, 82 + State = 3, 83 + } 84 + 85 + impl TrackId { 86 + pub(crate) fn from_name(name: &str) -> Option<Self> { 87 + match name { 88 + "audio" => Some(TrackId::Audio), 89 + "video" => Some(TrackId::Video), 90 + "chat" => Some(TrackId::Chat), 91 + "state" => Some(TrackId::State), 92 + _ => None, 93 + } 94 + } 95 + } 96 + 97 + /// A single MoQ object on a track. 98 + #[derive(Debug, Clone)] 99 + pub(crate) struct MoqObject { 100 + pub(crate) track_id: TrackId, 101 + pub(crate) group_id: u64, 102 + pub(crate) object_id: u64, 103 + pub(crate) payload: Bytes, 104 + } 105 + 106 + /// State track value (latest-only semantics via `watch`). 107 + #[derive(Debug, Clone)] 108 + pub(crate) struct StateValue { 109 + pub(crate) payload: Bytes, 110 + pub(crate) seq: u64, 111 + } 112 + 113 + /// Per-room MoQ track publishers, shared across all client sessions. 114 + /// 115 + /// Cloning is cheap (all inner channels are `Arc`-based). 116 + #[derive(Clone)] 117 + pub(crate) struct TrackPublishers { 118 + pub(crate) audio: broadcast::Sender<MoqObject>, 119 + pub(crate) video: broadcast::Sender<MoqObject>, 120 + pub(crate) chat: broadcast::Sender<MoqObject>, 121 + pub(crate) state: watch::Sender<StateValue>, 122 + /// Cached fMP4 init segment (ftyp+moov boxes) for late-joining clients. 123 + video_init: Arc<Mutex<Option<(u64, Bytes)>>>, // (group_id, payload) 124 + video_init_watch: watch::Sender<bool>, 125 + chat_seq: Arc<AtomicU64>, 126 + } 127 + 128 + impl TrackPublishers { 129 + pub(crate) fn new() -> Self { 130 + let (audio, _) = broadcast::channel(256); 131 + let (video, _) = broadcast::channel(256); 132 + let (chat, _) = broadcast::channel(256); 133 + let (state, _) = watch::channel(StateValue { 134 + payload: Bytes::new(), 135 + seq: 0, 136 + }); 137 + let (video_init_watch, _) = watch::channel(false); 138 + TrackPublishers { 139 + audio, 140 + video, 141 + chat, 142 + state, 143 + video_init: Arc::new(Mutex::new(None)), 144 + video_init_watch, 145 + chat_seq: Arc::new(AtomicU64::new(0)), 146 + } 147 + } 148 + 149 + pub(crate) fn publish_video(&self, group_id: u64, object_id: u64, payload: Bytes) { 150 + let _ = self.video.send(MoqObject { 151 + track_id: TrackId::Video, 152 + group_id, 153 + object_id, 154 + payload, 155 + }); 156 + } 157 + 158 + /// Publish a chat object with an auto-incrementing object_id. 159 + pub(crate) fn publish_chat(&self, group_id: u64, payload: Bytes) { 160 + let object_id = self.chat_seq.fetch_add(1, Ordering::Relaxed); 161 + let _ = self.chat.send(MoqObject { 162 + track_id: TrackId::Chat, 163 + group_id, 164 + object_id, 165 + payload, 166 + }); 167 + } 168 + 169 + /// Publish a state update (latest-only, atomically increments seq). 170 + pub(crate) fn publish_state(&self, payload: Bytes) { 171 + let prev = self.state.borrow().clone(); 172 + let new = StateValue { 173 + payload, 174 + seq: prev.seq + 1, 175 + }; 176 + let _ = self.state.send(new); 177 + } 178 + 179 + /// Cache the fMP4 init segment (ftyp+moov boxes) so late-joining clients 180 + /// can initialise their MediaSource before receiving live fragments. 181 + pub(crate) fn cache_init_segment(&self, payload: Bytes, group_id: u64) { 182 + tracing::debug!(group_id, size = payload.len(), "caching init segment"); 183 + if let Ok(mut guard) = self.video_init.lock() { 184 + *guard = Some((group_id, payload.clone())); 185 + } 186 + self.video_init_watch.send_replace(true); 187 + } 188 + 189 + pub(crate) fn get_init_segment(&self) -> Option<(u64, Bytes)> { 190 + let result = self.video_init.lock().ok().and_then(|g| g.clone()); 191 + if let Some((group_id, ref data)) = result { 192 + tracing::debug!(group_id, size = data.len(), "retrieved cached init segment"); 193 + } else { 194 + tracing::debug!("no cached init segment found"); 195 + } 196 + result 197 + } 198 + 199 + pub(crate) fn video_init_waiter(&self) -> watch::Receiver<bool> { 200 + self.video_init_watch.subscribe() 201 + } 202 + } 203 + 204 + /// All room operations — processed sequentially by the per-room actor. 205 + pub(crate) enum RoomCommand { 206 + QueueTrack(TrackMeta), 207 + Skip, 208 + /// Sent by the pipeline task when it finishes (or errors, or is aborted). 209 + TrackEnded { 210 + item_id: i64, 211 + }, 212 + Chat { 213 + user_name: String, 214 + content: String, 215 + msg_type: String, 216 + }, 217 + RegisterClient { 218 + resp: oneshot::Sender<u64>, 219 + }, 220 + UnregisterClient, 221 + /// Graceful shutdown (sent by idle sweeper). 222 + Shutdown, 223 + } 224 + 225 + /// Public handle to a room — allows sending commands and reading publishers. 226 + #[derive(Clone)] 227 + pub(crate) struct RoomHandle { 228 + pub(crate) cmd_tx: mpsc::Sender<RoomCommand>, 229 + pub(crate) publishers: TrackPublishers, 230 + pub(crate) last_active: watch::Receiver<DateTime<Utc>>, 231 + } 232 + 233 + #[cfg(test)] 234 + mod tests { 235 + use super::*; 236 + use std::time::Duration; 237 + 238 + #[test] 239 + fn test_room_id_display() { 240 + let id = RoomId("12345".into()); 241 + assert_eq!(id.to_string(), "12345"); 242 + } 243 + 244 + #[test] 245 + fn test_track_id_from_name() { 246 + assert_eq!(TrackId::from_name("audio"), Some(TrackId::Audio)); 247 + assert_eq!(TrackId::from_name("video"), Some(TrackId::Video)); 248 + assert_eq!(TrackId::from_name("chat"), Some(TrackId::Chat)); 249 + assert_eq!(TrackId::from_name("state"), Some(TrackId::State)); 250 + assert_eq!(TrackId::from_name("invalid"), None); 251 + assert_eq!(TrackId::from_name(""), None); 252 + } 253 + 254 + /// Test 1: Verify cache_init_segment stores and get_init_segment retrieves, 255 + /// including correct group_id matching. 256 + #[test] 257 + fn test_init_cache_and_retrieve() { 258 + let publishers = TrackPublishers::new(); 259 + 260 + // Should be None initially 261 + assert!( 262 + publishers.get_init_segment().is_none(), 263 + "init should be None before caching" 264 + ); 265 + 266 + // Cache with group_id=42 267 + let payload = Bytes::from("ftyp-moov-data"); 268 + publishers.cache_init_segment(payload.clone(), 42); 269 + 270 + // Retrieve and verify 271 + let retrieved = publishers.get_init_segment(); 272 + assert!(retrieved.is_some(), "init should be Some after caching"); 273 + let (group_id, data) = retrieved.unwrap(); 274 + assert_eq!(group_id, 42, "group_id should match what was cached"); 275 + assert_eq!(data, payload, "payload should match what was cached"); 276 + } 277 + 278 + /// Test 2: Verify that the video_init_watch fires when cache_init_segment is called, 279 + /// so a task waiting on it can proceed. 280 + #[tokio::test] 281 + async fn test_init_watch_signals() { 282 + let publishers = TrackPublishers::new(); 283 + 284 + // Get a waiter before the init is cached 285 + let mut waiter = publishers.video_init_waiter(); 286 + assert!(!*waiter.borrow(), "initial watch value should be false"); 287 + 288 + // Spawn a task that caches init after a short delay 289 + let pubs = publishers.clone(); 290 + tokio::spawn(async move { 291 + tokio::time::sleep(Duration::from_millis(50)).await; 292 + pubs.cache_init_segment(Bytes::from("init-payload"), 1); 293 + }); 294 + 295 + // Wait for the watch to fire (with timeout) 296 + let _ = tokio::time::timeout(Duration::from_secs(2), waiter.changed()) 297 + .await 298 + .expect("watch should fire within timeout when init is cached"); 299 + 300 + // After the watch fires, the value should be true 301 + assert!( 302 + *waiter.borrow(), 303 + "watch value should be true after cache_init_segment" 304 + ); 305 + 306 + // And the init should be retrievable 307 + let retrieved = publishers.get_init_segment(); 308 + assert!( 309 + retrieved.is_some(), 310 + "init should be retrievable after watch fires" 311 + ); 312 + let (group_id, data) = retrieved.unwrap(); 313 + assert_eq!(group_id, 1, "group_id should match"); 314 + assert_eq!(data, Bytes::from("init-payload"), "payload should match"); 315 + } 316 + 317 + /// Verify caching a new init segment for a new group overwrites the old one. 318 + #[test] 319 + fn test_cache_overwrite_on_new_group() { 320 + let publishers = TrackPublishers::new(); 321 + 322 + publishers.cache_init_segment(Bytes::from("init-1"), 1); 323 + publishers.cache_init_segment(Bytes::from("init-2"), 2); 324 + 325 + let retrieved = publishers.get_init_segment(); 326 + assert!(retrieved.is_some(), "init should be cached"); 327 + let (group_id, data) = retrieved.unwrap(); 328 + assert_eq!(group_id, 2, "group_id should be from the latest cache call"); 329 + assert_eq!( 330 + data, 331 + Bytes::from("init-2"), 332 + "payload should be from the latest cache call" 333 + ); 334 + } 335 + }
+305
src/web.rs
··· 1 + //! SSR frontend — Axum router, handlers, and templates. 2 + 3 + use std::collections::HashMap; 4 + use std::net::SocketAddr; 5 + use std::path::PathBuf; 6 + use std::sync::Arc; 7 + 8 + use askama::Template; 9 + use axum::{ 10 + Router, 11 + extract::{ConnectInfo, Path, Query, State, WebSocketUpgrade}, 12 + http::StatusCode, 13 + response::{Html, IntoResponse, Json}, 14 + routing::{get, post}, 15 + }; 16 + use sqlx::{Pool, Sqlite}; 17 + use tokio::sync::Mutex; 18 + 19 + use crate::media; 20 + use crate::room; 21 + use crate::types::{RoomId, TrackMeta}; 22 + 23 + /// Per-IP rate limiter for POST /api/ingest (max 1 req/5s per IP). 24 + #[derive(Clone)] 25 + pub(crate) struct RateLimiter { 26 + inner: Arc<Mutex<HashMap<SocketAddr, tokio::time::Instant>>>, 27 + } 28 + 29 + impl RateLimiter { 30 + pub(crate) fn new() -> Self { 31 + Self { 32 + inner: Arc::new(Mutex::new(HashMap::new())), 33 + } 34 + } 35 + 36 + /// Returns `Ok(())` if allowed, or `Err(duration_to_wait)` if rate limited. 37 + pub(crate) async fn check(&self, addr: SocketAddr) -> Result<(), std::time::Duration> { 38 + let mut map = self.inner.lock().await; 39 + if let Some(last) = map.get(&addr) { 40 + let elapsed = last.elapsed(); 41 + if elapsed < std::time::Duration::from_secs(5) { 42 + return Err(std::time::Duration::from_secs(5) - elapsed); 43 + } 44 + } 45 + map.insert(addr, tokio::time::Instant::now()); 46 + Ok(()) 47 + } 48 + 49 + /// Remove entries older than 30 seconds to prevent memory leak. 50 + pub(crate) async fn cleanup(&self) { 51 + let mut map = self.inner.lock().await; 52 + map.retain(|_, last| last.elapsed() < std::time::Duration::from_secs(30)); 53 + } 54 + } 55 + 56 + #[derive(Clone)] 57 + pub(crate) struct AppState { 58 + pub rooms: room::Registry, 59 + pub pool: Pool<Sqlite>, 60 + pub cache_dir: PathBuf, 61 + pub rate_limiter: RateLimiter, 62 + } 63 + 64 + pub(crate) fn router(rooms: room::Registry, pool: Pool<Sqlite>, cache_dir: PathBuf) -> Router { 65 + let rate_limiter = RateLimiter::new(); 66 + 67 + // Periodic cleanup for rate limiter entries (every 30s). 68 + let rl = rate_limiter.clone(); 69 + tokio::spawn(async move { 70 + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); 71 + loop { 72 + interval.tick().await; 73 + rl.cleanup().await; 74 + } 75 + }); 76 + 77 + let state = AppState { 78 + rooms, 79 + pool, 80 + cache_dir, 81 + rate_limiter, 82 + }; 83 + 84 + Router::new() 85 + .route("/", get(index)) 86 + .route("/room/{id}", get(room_view)) 87 + .route("/api/rooms", post(create_room)) 88 + .route("/api/ingest", post(ingest_url)) 89 + .route("/api/skip", post(skip_handler)) 90 + .route("/ws/{room_id}", get(ws_handler)) 91 + .with_state(state) 92 + } 93 + 94 + #[derive(Template)] 95 + #[template(path = "index.html")] 96 + struct IndexTemplate { 97 + version: String, 98 + } 99 + 100 + #[derive(Template)] 101 + #[template(path = "room.html")] 102 + struct RoomTemplate { 103 + room_id: String, 104 + queue: Vec<TrackMeta>, 105 + history: Vec<TrackMeta>, 106 + queue_empty: bool, 107 + } 108 + 109 + async fn index() -> Html<String> { 110 + let tpl = IndexTemplate { 111 + version: env!("CARGO_PKG_VERSION").into(), 112 + }; 113 + Html( 114 + tpl.render() 115 + .unwrap_or_else(|e| format!("template error: {e}")), 116 + ) 117 + } 118 + 119 + async fn room_view( 120 + State(state): State<AppState>, 121 + Path(id): Path<String>, 122 + ) -> Result<Html<String>, StatusCode> { 123 + // Validate room ID format (snowflake digits only). 124 + if !id.chars().all(|c| c.is_ascii_digit()) { 125 + return Err(StatusCode::BAD_REQUEST); 126 + } 127 + let room_id = RoomId(id.clone()); 128 + let queue = state.rooms.queue(&room_id).await; 129 + let queue_empty = queue.is_empty(); 130 + 131 + // Fetch history via Playlist. 132 + let history = { 133 + let playlist = crate::playlist::Playlist::new(state.pool.clone(), &id); 134 + playlist 135 + .history(20) 136 + .await 137 + .unwrap_or_default() 138 + .into_iter() 139 + .map(|i| TrackMeta { 140 + title: i.title, 141 + duration: i.duration, 142 + thumbnail: i.thumbnail, 143 + url: i.url, 144 + extractor: i.extractor, 145 + }) 146 + .collect::<Vec<_>>() 147 + }; 148 + 149 + let tpl = RoomTemplate { 150 + room_id: id, 151 + queue, 152 + history, 153 + queue_empty, 154 + }; 155 + Ok(Html( 156 + tpl.render() 157 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, 158 + )) 159 + } 160 + 161 + async fn create_room(State(state): State<AppState>) -> Result<impl IntoResponse, StatusCode> { 162 + let room = state.rooms.create().await; 163 + Ok(Json(serde_json::json!({ "room_id": room.id.0 }))) 164 + } 165 + 166 + #[derive(serde::Deserialize)] 167 + struct IngestRequest { 168 + url: String, 169 + room_id: String, 170 + } 171 + 172 + #[derive(serde::Serialize)] 173 + struct IngestResponse { 174 + title: String, 175 + duration: String, 176 + thumbnail: Option<String>, 177 + } 178 + 179 + fn err_response(status: StatusCode, msg: &str) -> axum::response::Response { 180 + (status, Json(serde_json::json!({ "error": msg }))).into_response() 181 + } 182 + 183 + async fn ingest_url( 184 + State(state): State<AppState>, 185 + ConnectInfo(addr): ConnectInfo<SocketAddr>, 186 + axum::Json(req): axum::Json<IngestRequest>, 187 + ) -> Result<Json<IngestResponse>, axum::response::Response> { 188 + // Input validation — URL max 2048 characters. 189 + if req.url.len() > 2048 { 190 + return Err(err_response(StatusCode::BAD_REQUEST, "URL too long")); 191 + } 192 + 193 + // Input validation — room ID must be digits (snowflake format). 194 + if !req.room_id.chars().all(|c| c.is_ascii_digit()) { 195 + return Err(err_response(StatusCode::BAD_REQUEST, "invalid room ID")); 196 + } 197 + 198 + // Rate limiting — max 1 request per 5 seconds per IP. 199 + match state.rate_limiter.check(addr).await { 200 + Err(retry_after) => { 201 + let mut res = err_response(StatusCode::TOO_MANY_REQUESTS, "rate limited"); 202 + res.headers_mut().insert( 203 + axum::http::header::RETRY_AFTER, 204 + retry_after.as_secs().to_string().parse().unwrap(), 205 + ); 206 + return Err(res); 207 + } 208 + Ok(()) => {} 209 + } 210 + 211 + let room_id = RoomId(req.room_id); 212 + 213 + // Validate the room exists. 214 + if !state.rooms.exists(&room_id).await { 215 + return Err(err_response(StatusCode::NOT_FOUND, "room not found")); 216 + } 217 + 218 + // Run yt-dlp extraction. 219 + let meta = media::extract(&req.url, &state.cache_dir) 220 + .await 221 + .map_err(|e| err_response(StatusCode::BAD_REQUEST, &e.to_string()))?; 222 + 223 + // Push to room queue. 224 + state.rooms.push(&room_id, meta.clone()).await; 225 + 226 + Ok(Json(IngestResponse { 227 + title: meta.title, 228 + duration: meta.duration, 229 + thumbnail: meta.thumbnail, 230 + })) 231 + } 232 + 233 + #[derive(serde::Deserialize)] 234 + struct SkipRequest { 235 + room_id: String, 236 + } 237 + 238 + async fn skip_handler( 239 + State(state): State<AppState>, 240 + axum::Json(req): axum::Json<SkipRequest>, 241 + ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> { 242 + let room_id = RoomId(req.room_id); 243 + 244 + // Validate the room exists. 245 + if !state.rooms.exists(&room_id).await { 246 + return Err(( 247 + StatusCode::NOT_FOUND, 248 + Json(serde_json::json!({ "error": "room not found" })), 249 + )); 250 + } 251 + 252 + state.rooms.skip(&room_id).await; 253 + 254 + Ok(Json(serde_json::json!({ "ok": true }))) 255 + } 256 + 257 + /// WebSocket upgrade endpoint: `/ws/{room_id}?name={user_name}`. 258 + /// 259 + /// Validates the room exists, then upgrades to the MoQ session handler. 260 + async fn ws_handler( 261 + ws: WebSocketUpgrade, 262 + Path(room_id): Path<String>, 263 + Query(params): Query<HashMap<String, String>>, 264 + State(state): State<AppState>, 265 + ) -> Result<impl IntoResponse, (StatusCode, &'static str)> { 266 + // Validate room ID format (snowflake digits only). 267 + if !room_id.chars().all(|c| c.is_ascii_digit()) { 268 + return Err((StatusCode::BAD_REQUEST, "invalid room ID")); 269 + } 270 + 271 + let rid = RoomId(room_id); 272 + 273 + // Reject unknown rooms before upgrading. 274 + if !state.rooms.exists(&rid).await { 275 + return Err((StatusCode::NOT_FOUND, "room not found")); 276 + } 277 + 278 + let handle = state 279 + .rooms 280 + .handle(&rid) 281 + .await 282 + .ok_or((StatusCode::NOT_FOUND, "room not found"))?; 283 + 284 + let user_name = params 285 + .get("name") 286 + .cloned() 287 + .unwrap_or_else(|| "anonymous".into()); 288 + 289 + // Validate username. 290 + if user_name.is_empty() || user_name.len() > 32 { 291 + return Err((StatusCode::BAD_REQUEST, "invalid username")); 292 + } 293 + 294 + Ok(ws.on_upgrade(move |socket| async move { 295 + crate::transport::handle_ws_session( 296 + socket, 297 + rid, 298 + &state.rooms, 299 + handle.cmd_tx, 300 + handle.publishers, 301 + user_name, 302 + ) 303 + .await; 304 + })) 305 + }
+187
templates/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>moqbox</title> 7 + <style> 8 + :root { 9 + --base: #24273a; 10 + --mantle: #1e2030; 11 + --crust: #181926; 12 + --surface0: #363a4f; 13 + --surface1: #494d64; 14 + --surface2: #5b6078; 15 + --overlay0: #6c7086; 16 + --overlay1: #7f849c; 17 + --overlay2: #939ab7; 18 + --subtext0: #a5adcb; 19 + --subtext1: #b8c0e0; 20 + --text: #cad3f5; 21 + --lavender: #b7bdf8; 22 + --blue: #8aadf4; 23 + --sapphire: #7dc4e4; 24 + --sky: #91d7e3; 25 + --teal: #8bd5ca; 26 + --green: #a6da95; 27 + --yellow: #eed49f; 28 + --peach: #f5a97f; 29 + --maroon: #ee99a0; 30 + --red: #ed8796; 31 + --mauve: #c6a0f6; 32 + --pink: #f5bde6; 33 + --flamingo: #f0c6c6; 34 + --rosewater: #f4dbd6; 35 + --radius-sm: 6px; 36 + --radius-md: 10px; 37 + --radius-lg: 14px; 38 + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; 39 + } 40 + *, 41 + *::before, 42 + *::after { 43 + box-sizing: border-box; 44 + margin: 0; 45 + padding: 0; 46 + } 47 + html { 48 + -webkit-font-smoothing: antialiased; 49 + -moz-osx-font-smoothing: grayscale; 50 + } 51 + body { 52 + font-family: var(--font-sans); 53 + background: var(--base); 54 + color: var(--text); 55 + min-height: 100vh; 56 + display: grid; 57 + place-items: center; 58 + padding: 1.5rem; 59 + } 60 + .card { 61 + background: var(--surface0); 62 + border-radius: var(--radius-lg); 63 + box-shadow: 64 + 0 8px 32px rgba(0, 0, 0, 0.35), 65 + 0 2px 8px rgba(0, 0, 0, 0.2); 66 + padding: 3rem 2.5rem; 67 + max-width: 420px; 68 + width: 100%; 69 + text-align: center; 70 + } 71 + h1 { 72 + font-size: 2.5rem; 73 + font-weight: 700; 74 + letter-spacing: -0.02em; 75 + text-wrap: balance; 76 + color: var(--text); 77 + } 78 + .card p { 79 + color: var(--subtext0); 80 + margin-top: 0.75rem; 81 + line-height: 1.6; 82 + font-size: 0.9375rem; 83 + } 84 + .actions { 85 + margin-top: 2rem; 86 + display: flex; 87 + flex-direction: column; 88 + gap: 0.75rem; 89 + } 90 + input, 91 + button { 92 + font: inherit; 93 + padding: 0.75rem 1.25rem; 94 + border-radius: var(--radius-md); 95 + border: none; 96 + background: var(--mantle); 97 + color: var(--text); 98 + font-size: 1rem; 99 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); 100 + } 101 + input { 102 + transition: box-shadow 150ms ease-out; 103 + } 104 + input:focus { 105 + outline: none; 106 + box-shadow: 107 + 0 0 0 2px var(--blue), 108 + 0 4px 16px rgba(0, 0, 0, 0.3); 109 + } 110 + input::placeholder { 111 + color: var(--overlay0); 112 + } 113 + button { 114 + background: var(--green); 115 + color: var(--crust); 116 + cursor: pointer; 117 + font-weight: 600; 118 + transition: 119 + transform 150ms ease-out, 120 + box-shadow 150ms ease-out, 121 + filter 150ms ease-out; 122 + min-height: 48px; 123 + display: inline-flex; 124 + align-items: center; 125 + justify-content: center; 126 + } 127 + button:hover { 128 + filter: brightness(1.12); 129 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 130 + } 131 + button:active { 132 + transform: scale(0.96); 133 + } 134 + .divider { 135 + color: var(--overlay0); 136 + font-size: 0.875rem; 137 + } 138 + .footer { 139 + margin-top: 3rem; 140 + font-size: 0.875rem; 141 + } 142 + .footer a { 143 + color: var(--blue); 144 + text-decoration: none; 145 + transition: color 150ms ease-out; 146 + } 147 + .footer a:hover { 148 + color: var(--sapphire); 149 + } 150 + </style> 151 + </head> 152 + <body> 153 + <main class="card"> 154 + <h1>moqbox</h1> 155 + <p> 156 + Ultra-low latency shared media rooms over MoQ.<br /> 157 + Paste a link from YouTube, SoundCloud, or Bandcamp to start. 158 + </p> 159 + 160 + <div class="actions"> 161 + <button type="button" onclick="createRoom()">Create Room</button> 162 + <span class="divider">or</span> 163 + <input 164 + type="text" 165 + id="room-code" 166 + placeholder="Enter room code..." 167 + maxlength="64" 168 + onkeydown="if(event.key==='Enter'){window.location='/room/'+this.value}" 169 + /> 170 + </div> 171 + 172 + <div class="footer"> 173 + <a href="https://tangled.org/karitham.dev/moqbox" 174 + >moqbox v{{ version }}</a 175 + > 176 + </div> 177 + </main> 178 + 179 + <script> 180 + async function createRoom() { 181 + const resp = await fetch("/api/rooms", { method: "POST" }); 182 + const data = await resp.json(); 183 + window.location.href = "/room/" + data.room_id; 184 + } 185 + </script> 186 + </body> 187 + </html>
+1185
templates/room.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>{{ room_id }} — moqbox</title> 7 + <style> 8 + :root { 9 + --base: #24273a; 10 + --mantle: #1e2030; 11 + --crust: #181926; 12 + --surface0: #363a4f; 13 + --surface1: #494d64; 14 + --surface2: #5b6078; 15 + --overlay0: #6c7086; 16 + --overlay1: #7f849c; 17 + --overlay2: #939ab7; 18 + --subtext0: #a5adcb; 19 + --subtext1: #b8c0e0; 20 + --text: #cad3f5; 21 + --lavender: #b7bdf8; 22 + --blue: #8aadf4; 23 + --sapphire: #7dc4e4; 24 + --sky: #91d7e3; 25 + --teal: #8bd5ca; 26 + --green: #a6da95; 27 + --yellow: #eed49f; 28 + --peach: #f5a97f; 29 + --maroon: #ee99a0; 30 + --red: #ed8796; 31 + --mauve: #c6a0f6; 32 + --pink: #f5bde6; 33 + --flamingo: #f0c6c6; 34 + --rosewater: #f4dbd6; 35 + --radius-sm: 6px; 36 + --radius-md: 10px; 37 + --radius-lg: 14px; 38 + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; 39 + } 40 + *, 41 + *::before, 42 + *::after { 43 + box-sizing: border-box; 44 + margin: 0; 45 + padding: 0; 46 + } 47 + html { 48 + -webkit-font-smoothing: antialiased; 49 + -moz-osx-font-smoothing: grayscale; 50 + } 51 + body { 52 + font-family: var(--font-sans); 53 + background: var(--base); 54 + color: var(--text); 55 + padding: 1.5rem; 56 + min-height: 100vh; 57 + } 58 + 59 + /* Top bar */ 60 + .top-bar { 61 + display: flex; 62 + align-items: center; 63 + gap: 0.75rem; 64 + margin-bottom: 1.5rem; 65 + } 66 + .back-link { 67 + display: inline-flex; 68 + align-items: center; 69 + gap: 0.375rem; 70 + color: var(--blue); 71 + text-decoration: none; 72 + font-size: 0.9375rem; 73 + font-weight: 500; 74 + padding: 0.5rem 1rem; 75 + border-radius: var(--radius-md); 76 + background: var(--surface0); 77 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 78 + transition: 79 + background 150ms ease-out, 80 + box-shadow 150ms ease-out; 81 + min-height: 40px; 82 + } 83 + .back-link:hover { 84 + background: var(--surface1); 85 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 86 + } 87 + .room-id { 88 + font-weight: 600; 89 + font-size: 1.125rem; 90 + text-wrap: balance; 91 + color: var(--subtext0); 92 + } 93 + 94 + /* Layout */ 95 + .layout { 96 + display: flex; 97 + gap: 1.5rem; 98 + } 99 + .main-col { 100 + flex: 1; 101 + min-width: 0; 102 + display: flex; 103 + flex-direction: column; 104 + } 105 + .chat-col { 106 + width: 340px; 107 + flex-shrink: 0; 108 + display: flex; 109 + flex-direction: column; 110 + } 111 + 112 + /* Video */ 113 + #video-player { 114 + width: 100%; 115 + max-height: 85vh; 116 + object-fit: contain; 117 + background: var(--crust); 118 + border-radius: var(--radius-lg); 119 + display: block; 120 + margin-bottom: 1.5rem; 121 + box-shadow: 122 + 0 8px 32px rgba(0, 0, 0, 0.35), 123 + 0 2px 8px rgba(0, 0, 0, 0.2); 124 + } 125 + 126 + /* Now Playing */ 127 + #now-playing { 128 + background: var(--surface0); 129 + border-radius: var(--radius-md); 130 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 131 + padding: 0.75rem 1rem; 132 + margin-bottom: 0.75rem; 133 + font-size: 0.9375rem; 134 + display: flex; 135 + align-items: center; 136 + flex-wrap: wrap; 137 + gap: 0.25rem 0.5rem; 138 + } 139 + #now-playing strong { 140 + font-weight: 600; 141 + color: var(--mauve); 142 + } 143 + #elapsed { 144 + font-variant-numeric: tabular-nums; 145 + } 146 + 147 + /* Input Row */ 148 + .input-row { 149 + display: flex; 150 + gap: 0.5rem; 151 + margin-bottom: 1.5rem; 152 + } 153 + .input-row input { 154 + flex: 1; 155 + font: inherit; 156 + padding: 0.75rem 1rem; 157 + border-radius: var(--radius-md); 158 + border: none; 159 + background: var(--mantle); 160 + color: var(--text); 161 + font-size: 0.9375rem; 162 + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); 163 + transition: box-shadow 150ms ease-out; 164 + } 165 + .input-row input:focus { 166 + outline: none; 167 + box-shadow: 168 + inset 0 2px 4px rgba(0, 0, 0, 0.2), 169 + 0 0 0 2px var(--blue); 170 + } 171 + .input-row input::placeholder { 172 + color: var(--overlay0); 173 + } 174 + 175 + /* Buttons */ 176 + button { 177 + font: inherit; 178 + padding: 0.75rem 1.25rem; 179 + border-radius: var(--radius-md); 180 + border: none; 181 + cursor: pointer; 182 + font-weight: 600; 183 + font-size: 0.9375rem; 184 + transition: 185 + transform 150ms ease-out, 186 + box-shadow 150ms ease-out, 187 + filter 150ms ease-out; 188 + min-height: 44px; 189 + display: inline-flex; 190 + align-items: center; 191 + justify-content: center; 192 + } 193 + button:active { 194 + transform: scale(0.96); 195 + } 196 + button.btn-primary { 197 + background: var(--green); 198 + color: var(--crust); 199 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); 200 + } 201 + button.btn-primary:hover { 202 + filter: brightness(1.12); 203 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 204 + } 205 + button.btn-secondary { 206 + background: var(--surface1); 207 + color: var(--text); 208 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); 209 + } 210 + button.btn-secondary:hover { 211 + background: var(--surface2); 212 + filter: brightness(1.08); 213 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 214 + } 215 + button.btn-accent { 216 + background: var(--mauve); 217 + color: var(--crust); 218 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); 219 + } 220 + button.btn-accent:hover { 221 + filter: brightness(1.12); 222 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 223 + } 224 + 225 + /* Queue */ 226 + .queue { 227 + list-style: none; 228 + } 229 + .queue li { 230 + background: var(--surface0); 231 + border-radius: var(--radius-md); 232 + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); 233 + padding: 0.75rem; 234 + display: flex; 235 + align-items: center; 236 + gap: 0.75rem; 237 + margin-bottom: 0.5rem; 238 + transition: 239 + background 150ms ease-out, 240 + box-shadow 150ms ease-out; 241 + animation: queueFadeIn 0.3s ease-out both; 242 + } 243 + .queue li:hover { 244 + background: var(--surface1); 245 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 246 + } 247 + .queue li .thumb, 248 + .queue li .thumb-img { 249 + width: 40px; 250 + height: 40px; 251 + border-radius: var(--radius-sm); 252 + background: var(--mantle); 253 + flex-shrink: 0; 254 + object-fit: cover; 255 + outline: 1px solid var(--surface2); 256 + outline-offset: -1px; 257 + } 258 + .queue li .title { 259 + flex: 1; 260 + min-width: 0; 261 + overflow: hidden; 262 + text-overflow: ellipsis; 263 + white-space: nowrap; 264 + } 265 + .queue li .duration { 266 + color: var(--subtext0); 267 + font-size: 0.875rem; 268 + flex-shrink: 0; 269 + font-variant-numeric: tabular-nums; 270 + } 271 + 272 + /* Source link */ 273 + .source-link { 274 + color: var(--blue); 275 + text-decoration: none; 276 + font-size: 0.8rem; 277 + padding: 2px 8px; 278 + border-radius: var(--radius-sm); 279 + background: color-mix(in srgb, var(--blue) 15%, transparent); 280 + transition: background 150ms ease-out; 281 + flex-shrink: 0; 282 + } 283 + .source-link:hover { 284 + background: color-mix(in srgb, var(--blue) 25%, transparent); 285 + } 286 + 287 + /* History header */ 288 + .history-header { 289 + margin-top: 1rem; 290 + margin-bottom: 0.5rem; 291 + font-size: 0.9375rem; 292 + color: var(--subtext0); 293 + font-weight: 600; 294 + text-wrap: balance; 295 + } 296 + 297 + /* Empty state */ 298 + .empty { 299 + color: var(--overlay0); 300 + text-align: center; 301 + padding: 3rem 1rem; 302 + font-size: 0.9375rem; 303 + } 304 + 305 + /* Chat */ 306 + .chat-messages { 307 + flex: 1; 308 + overflow-y: auto; 309 + max-height: calc(100vh - 180px); 310 + background: var(--surface0); 311 + border-radius: var(--radius-lg); 312 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 313 + padding: 0.5rem; 314 + display: flex; 315 + flex-direction: column; 316 + gap: 4px; 317 + margin-bottom: 0.5rem; 318 + } 319 + .chat-msg { 320 + padding: 6px 10px; 321 + border-left: 3px solid var(--blue); 322 + padding-left: 10px; 323 + font-size: 0.875rem; 324 + line-height: 1.4; 325 + } 326 + .chat-msg .user { 327 + color: var(--blue); 328 + font-weight: 600; 329 + font-size: 0.8rem; 330 + } 331 + .chat-msg .time { 332 + color: var(--overlay0); 333 + font-size: 0.7rem; 334 + float: right; 335 + margin-left: 8px; 336 + } 337 + .chat-msg .text { 338 + color: var(--text); 339 + } 340 + .chat-msg.system { 341 + border-left-color: var(--overlay0); 342 + } 343 + .chat-msg.system .user { 344 + color: var(--overlay0); 345 + } 346 + .chat-msg.system .text { 347 + color: var(--subtext0); 348 + font-style: italic; 349 + } 350 + 351 + /* Chat input */ 352 + .chat-input-row { 353 + display: flex; 354 + gap: 0.5rem; 355 + } 356 + .chat-input-row input { 357 + flex: 1; 358 + font: inherit; 359 + padding: 0.625rem 0.875rem; 360 + border-radius: var(--radius-md); 361 + border: none; 362 + background: var(--mantle); 363 + color: var(--text); 364 + font-size: 0.875rem; 365 + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); 366 + transition: box-shadow 150ms ease-out; 367 + } 368 + .chat-input-row input:focus { 369 + outline: none; 370 + box-shadow: 371 + inset 0 2px 4px rgba(0, 0, 0, 0.2), 372 + 0 0 0 2px var(--blue); 373 + } 374 + .chat-input-row input::placeholder { 375 + color: var(--overlay0); 376 + } 377 + .chat-input-row button { 378 + padding: 0.625rem 1rem; 379 + min-height: 40px; 380 + background: var(--mauve); 381 + color: var(--crust); 382 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); 383 + } 384 + .chat-input-row button:hover { 385 + filter: brightness(1.12); 386 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 387 + } 388 + 389 + /* Stagger animation */ 390 + @keyframes queueFadeIn { 391 + from { 392 + opacity: 0; 393 + transform: translateY(8px); 394 + } 395 + to { 396 + opacity: 1; 397 + transform: translateY(0); 398 + } 399 + } 400 + .queue li:nth-child(1) { 401 + animation-delay: 0.02s; 402 + } 403 + .queue li:nth-child(2) { 404 + animation-delay: 0.04s; 405 + } 406 + .queue li:nth-child(3) { 407 + animation-delay: 0.06s; 408 + } 409 + .queue li:nth-child(4) { 410 + animation-delay: 0.08s; 411 + } 412 + .queue li:nth-child(5) { 413 + animation-delay: 0.1s; 414 + } 415 + .queue li:nth-child(6) { 416 + animation-delay: 0.12s; 417 + } 418 + .queue li:nth-child(7) { 419 + animation-delay: 0.14s; 420 + } 421 + .queue li:nth-child(8) { 422 + animation-delay: 0.16s; 423 + } 424 + .queue li:nth-child(9) { 425 + animation-delay: 0.18s; 426 + } 427 + .queue li:nth-child(10) { 428 + animation-delay: 0.2s; 429 + } 430 + .queue li:nth-child(11) { 431 + animation-delay: 0.22s; 432 + } 433 + .queue li:nth-child(12) { 434 + animation-delay: 0.24s; 435 + } 436 + 437 + /* Mobile responsive */ 438 + @media (max-width: 768px) { 439 + .layout { 440 + flex-direction: column; 441 + } 442 + .chat-col { 443 + width: 100%; 444 + } 445 + #video-player { 446 + max-height: 50vh !important; 447 + } 448 + } 449 + </style> 450 + </head> 451 + <body> 452 + <div class="top-bar"> 453 + <a href="/" class="back-link">&larr; Home</a> 454 + <span class="room-id">{{ room_id }}</span> 455 + </div> 456 + 457 + <div class="layout"> 458 + <div class="main-col"> 459 + <div id="video-container"></div> 460 + <div id="now-playing"></div> 461 + 462 + <div class="input-row"> 463 + <input 464 + type="text" 465 + id="url-input" 466 + placeholder="Paste YouTube / SoundCloud / Bandcamp URL..." 467 + /> 468 + <button class="btn-primary" onclick="ingest()">Add</button> 469 + <button 470 + class="btn-secondary" 471 + onclick="skip()" 472 + title="Skip current track" 473 + > 474 + Skip 475 + </button> 476 + </div> 477 + 478 + <div id="queue-section" style="min-height: 3rem"> 479 + {% if queue_empty %} 480 + <div class="empty"> 481 + Queue is empty. Paste a link above to get started. 482 + </div> 483 + {% else %} 484 + <ul class="queue"> 485 + {% for item in queue %} 486 + <li> 487 + {% if let Some(thumb) = item.thumbnail %} 488 + <img src="{{ thumb }}" class="thumb-img" alt="" /> 489 + {% else %} 490 + <div class="thumb"></div> 491 + {% endif %} 492 + <div class="title">{{ item.title }}</div> 493 + <div class="duration">{{ item.duration }}</div> 494 + <a 495 + href="{{ item.url }}" 496 + class="source-link" 497 + target="_blank" 498 + rel="noopener noreferrer" 499 + >source</a 500 + > 501 + </li> 502 + {% endfor %} 503 + </ul> 504 + {% endif %} 505 + </div> 506 + 507 + <div id="history-section"> 508 + {% if !history.is_empty() %} 509 + <div class="history-header">Played</div> 510 + <ul class="queue"> 511 + {% for item in history %} 512 + <li style="opacity: 0.7"> 513 + {% if let Some(thumb) = item.thumbnail %} 514 + <img src="{{ thumb }}" class="thumb-img" alt="" /> 515 + {% else %} 516 + <div class="thumb"></div> 517 + {% endif %} 518 + <div class="title">{{ item.title }}</div> 519 + <div class="duration">{{ item.duration }}</div> 520 + <a 521 + href="{{ item.url }}" 522 + class="source-link" 523 + target="_blank" 524 + rel="noopener noreferrer" 525 + >source</a 526 + > 527 + </li> 528 + {% endfor %} 529 + </ul> 530 + {% endif %} 531 + </div> 532 + </div> 533 + 534 + <div class="chat-col"> 535 + <div id="chat-messages" class="chat-messages"></div> 536 + <div class="chat-input-row"> 537 + <input 538 + type="text" 539 + id="chat-input" 540 + placeholder="Type a message..." 541 + maxlength="2000" 542 + /> 543 + <button id="chat-send" class="btn-accent">Send</button> 544 + </div> 545 + </div> 546 + </div> 547 + 548 + <script> 549 + // ── MoQ binary helpers ────────────────────────────────────────── 550 + function encVarint(n) { 551 + if (n < 64) return new Uint8Array([n]); 552 + if (n < 16384) return new Uint8Array([(n >> 8) | 0x40, n & 0xff]); 553 + if (n < 1073741824) 554 + return new Uint8Array([ 555 + (n >> 24) | 0x80, 556 + (n >> 16) & 0xff, 557 + (n >> 8) & 0xff, 558 + n & 0xff, 559 + ]); 560 + const b = new Uint8Array(8); 561 + b[0] = (n >> 56) | 0xc0; 562 + b[1] = (n >> 48) & 0xff; 563 + b[2] = (n >> 40) & 0xff; 564 + b[3] = (n >> 32) & 0xff; 565 + b[4] = (n >> 24) & 0xff; 566 + b[5] = (n >> 16) & 0xff; 567 + b[6] = (n >> 8) & 0xff; 568 + b[7] = n & 0xff; 569 + return b; 570 + } 571 + function encString(s) { 572 + const bytes = new TextEncoder().encode(s); 573 + const len = encVarint(bytes.length); 574 + const out = new Uint8Array(len.length + bytes.length); 575 + out.set(len); 576 + out.set(bytes, len.length); 577 + return out; 578 + } 579 + function concat(arrays) { 580 + const total = arrays.reduce((sum, arr) => sum + arr.length, 0); 581 + const out = new Uint8Array(total); 582 + let off = 0; 583 + for (const a of arrays) { 584 + out.set(a, off); 585 + off += a.length; 586 + } 587 + return out; 588 + } 589 + 590 + function decVarint(buf, off) { 591 + const first = buf[off]; 592 + const tag = first >> 6; 593 + if (tag === 0) return [first & 0x3f, off + 1]; 594 + if (tag === 1) return [((first & 0x3f) << 8) | buf[off + 1], off + 2]; 595 + if (tag === 2) 596 + return [ 597 + ((first & 0x3f) << 24) | 598 + (buf[off + 1] << 16) | 599 + (buf[off + 2] << 8) | 600 + buf[off + 3], 601 + off + 4, 602 + ]; 603 + let val = BigInt(first & 0x3f) << 56n; 604 + for (let i = 1; i < 8; i++) 605 + val |= BigInt(buf[off + i]) << BigInt(8 * (7 - i)); 606 + return [Number(val), off + 8]; 607 + } 608 + function decString(buf, off) { 609 + const [len, o] = decVarint(buf, off); 610 + const str = new TextDecoder().decode(buf.slice(o, o + len)); 611 + return [str, o + len]; 612 + } 613 + 614 + // ── Chat WebSocket client ─────────────────────────────────────── 615 + const roomId = "{{ room_id }}"; 616 + let userName = sessionStorage.getItem("moqbox_user"); 617 + if (!userName) { 618 + userName = prompt("Enter your name:", "anonymous") || "anonymous"; 619 + sessionStorage.setItem("moqbox_user", userName); 620 + } 621 + 622 + const proto = location.protocol === "https:" ? "wss:" : "ws:"; 623 + let ws = null; 624 + let reconnectDelay = 1000; 625 + 626 + // ── MediaSource video playback ──────────────────────────────────── 627 + let mediaSource = null; 628 + let sourceBuffer = null; 629 + const pendingBoxes = []; // buffered while waiting for sourceopen 630 + let currentTrackId = null; // tracks the currently playing track (from state) 631 + let awaitingInit = true; // true until an init segment is processed 632 + let seekInitDone = false; // true after first seek on join or track change 633 + 634 + function initVideo() { 635 + console.log( 636 + "initVideo() called, readyState:", 637 + mediaSource ? mediaSource.readyState : "null", 638 + ); 639 + const container = 640 + document.getElementById("video-container") || 641 + document.querySelector(".main-col"); 642 + const video = document.createElement("video"); 643 + video.id = "video-player"; 644 + video.controls = true; 645 + video.autoplay = true; 646 + video.muted = true; 647 + video.style.cssText = 648 + "width:100%;max-height:85vh;object-fit:contain;background:#000;display:block;"; 649 + container.insertBefore(video, container.firstChild); 650 + mediaSource = new MediaSource(); 651 + console.log( 652 + "initVideo: created MediaSource, readyState:", 653 + mediaSource.readyState, 654 + ); 655 + mediaSource.addEventListener("sourceopen", () => { 656 + console.log( 657 + "MediaSource sourceopen fired, readyState:", 658 + mediaSource.readyState, 659 + ); 660 + try { 661 + sourceBuffer = mediaSource.addSourceBuffer( 662 + 'video/mp4; codecs="avc1.4d0028,mp4a.40.2"', 663 + ); 664 + sourceBuffer.mode = "segments"; 665 + console.log( 666 + "SourceBuffer created, mode:", 667 + sourceBuffer.mode, 668 + "updating:", 669 + sourceBuffer.updating, 670 + ); 671 + sourceBuffer.addEventListener("updateend", () => { 672 + console.log( 673 + "SourceBuffer updateend, pendingBoxes.length:", 674 + pendingBoxes.length, 675 + ); 676 + if (pendingBoxes.length > 0) { 677 + const next = pendingBoxes.shift(); 678 + try { 679 + sourceBuffer.appendBuffer(next); 680 + } catch (e) { 681 + console.warn("SourceBuffer append failed:", e); 682 + } 683 + } 684 + // Late-join seek: after first media segment is appended, 685 + // seek to the start of buffered data to get out of the 686 + // t=0 stall. The periodic sync in updateNowPlaying handles 687 + // drift correction to the live position. 688 + if (!seekInitDone) { 689 + const v = document.getElementById("video-player"); 690 + if (v && v.buffered.length > 0) { 691 + seekInitDone = true; 692 + v.currentTime = v.buffered.start(0); 693 + console.log( 694 + "Initial seek to:", 695 + v.currentTime, 696 + "buffered:", 697 + v.buffered.start(0), 698 + "-", 699 + v.buffered.end(0), 700 + ); 701 + } 702 + } 703 + }); 704 + // Flush any boxes that arrived before sourceopen. 705 + console.log( 706 + "sourceopen flush: pendingBoxes.length before flush:", 707 + pendingBoxes.length, 708 + ); 709 + while (pendingBoxes.length > 0) { 710 + const box = pendingBoxes.shift(); 711 + if (!sourceBuffer.updating) { 712 + try { 713 + sourceBuffer.appendBuffer(box); 714 + } catch (e) { 715 + console.warn("SourceBuffer flush failed:", e); 716 + } 717 + } else { 718 + pendingBoxes.unshift(box); 719 + break; 720 + } 721 + } 722 + } catch (e) { 723 + console.warn("MediaSource not supported:", e); 724 + } 725 + }); 726 + video.src = URL.createObjectURL(mediaSource); 727 + } 728 + initVideo(); 729 + 730 + function appendVideo(data) { 731 + if (sourceBuffer && !sourceBuffer.updating) { 732 + console.log( 733 + "appendVideo: appending to sourceBuffer, size:", 734 + data.length, 735 + ); 736 + sourceBuffer.appendBuffer(data); 737 + } else { 738 + // Buffer if sourceBuffer is still null or busy updating. 739 + console.log( 740 + "appendVideo: buffering to pendingBoxes, size:", 741 + data.length, 742 + "sourceBuffer:", 743 + !!sourceBuffer, 744 + "updating:", 745 + sourceBuffer ? sourceBuffer.updating : "N/A", 746 + ); 747 + pendingBoxes.push(data); 748 + } 749 + } 750 + 751 + function subscribeTracks() { 752 + // Subscribe to chat track. 753 + ws.send( 754 + concat([ 755 + encVarint(0x01), 756 + encString("moqbox/room/" + roomId + "/chat"), 757 + encString("chat"), 758 + ]), 759 + ); 760 + // Subscribe to video track for MediaSource playback. 761 + ws.send( 762 + concat([ 763 + encVarint(0x01), 764 + encString("moqbox/room/" + roomId + "/video"), 765 + encString("video"), 766 + ]), 767 + ); 768 + // Subscribe to state track for now-playing updates. 769 + ws.send( 770 + concat([ 771 + encVarint(0x01), 772 + encString("moqbox/room/" + roomId + "/state"), 773 + encString("state"), 774 + ]), 775 + ); 776 + } 777 + 778 + function connect() { 779 + ws = new WebSocket( 780 + proto + 781 + "//" + 782 + location.host + 783 + "/ws/" + 784 + roomId + 785 + "?name=" + 786 + encodeURIComponent(userName), 787 + ); 788 + ws.binaryType = "arraybuffer"; 789 + 790 + ws.onopen = () => { 791 + console.log( 792 + "WS onopen: resetting awaitingInit=true, clearing pendingBoxes", 793 + ); 794 + reconnectDelay = 1000; 795 + // Full reset on reconnect — ensures init is re-sent and accepted. 796 + awaitingInit = true; 797 + pendingBoxes.length = 0; 798 + currentTrackId = null; 799 + seekInitDone = false; 800 + subscribeTracks(); 801 + }; 802 + 803 + ws.onmessage = (event) => { 804 + const buf = new Uint8Array(event.data); 805 + let off = 0; 806 + while (off < buf.length) { 807 + const [msgType, o1] = decVarint(buf, off); 808 + if (msgType === 0x00) { 809 + const [trackId, o2] = decVarint(buf, o1); 810 + const [groupId, o3] = decVarint(buf, o2); 811 + const [objectId, o4] = decVarint(buf, o3); 812 + if (trackId === 1) { 813 + const videoData = new Uint8Array(buf.slice(o4)); 814 + console.log( 815 + "Video obj received, size:", 816 + videoData.length, 817 + "awaitingInit:", 818 + awaitingInit, 819 + "pendingBoxes:", 820 + pendingBoxes.length, 821 + ); 822 + 823 + if (awaitingInit) { 824 + const isInit = 825 + videoData.length >= 8 && 826 + String.fromCharCode( 827 + videoData[4], 828 + videoData[5], 829 + videoData[6], 830 + videoData[7], 831 + ) === "ftyp"; 832 + 833 + console.log( 834 + "awaitingInit check: isInit:", 835 + isInit, 836 + "first 4 bytes:", 837 + String.fromCharCode( 838 + videoData[4], 839 + videoData[5], 840 + videoData[6], 841 + videoData[7], 842 + ), 843 + ); 844 + 845 + if (isInit) { 846 + console.log( 847 + "INIT RECEIVED — setting awaitingInit=false, clearing pendingBoxes", 848 + ); 849 + awaitingInit = false; 850 + pendingBoxes.length = 0; 851 + if (sourceBuffer && !sourceBuffer.updating) { 852 + try { 853 + sourceBuffer.abort(); 854 + if (sourceBuffer.buffered.length > 0) { 855 + const end = sourceBuffer.buffered.end( 856 + sourceBuffer.buffered.length - 1, 857 + ); 858 + sourceBuffer.remove(0, end); 859 + } 860 + } catch (_) {} 861 + } 862 + } else { 863 + // Buffer until init arrives. 864 + console.log( 865 + "Buffering non-init data, pendingBoxes now:", 866 + pendingBoxes.length + 1, 867 + ); 868 + pendingBoxes.push(videoData); 869 + off = buf.length; 870 + return; 871 + } 872 + } 873 + 874 + appendVideo(videoData); 875 + off = buf.length; 876 + } else if (trackId === 2) { 877 + const payload = new TextDecoder().decode(buf.slice(o4)); 878 + try { 879 + const chat = JSON.parse(payload); 880 + appendChat(chat); 881 + } catch (_) {} 882 + off = buf.length; 883 + } else if (trackId === 3) { 884 + const payload = new TextDecoder().decode(buf.slice(o4)); 885 + try { 886 + const state = JSON.parse(payload); 887 + updateNowPlaying(state); 888 + } catch (_) {} 889 + off = buf.length; 890 + } else { 891 + off = buf.length; 892 + } 893 + } else if (msgType === 0x02) { 894 + // SUBSCRIBE_OK — just advance past it. 895 + const [ns, o2] = decString(buf, o1); 896 + const [tr, o3] = decString(buf, o2); 897 + off = o3; 898 + } else if (msgType === 0x06) { 899 + // SUBSCRIBE_RST — advance past it. 900 + const [ns, o2] = decString(buf, o1); 901 + const [tr, o3] = decString(buf, o2); 902 + const [reason, o4] = decString(buf, o3); 903 + off = o4; 904 + } else { 905 + break; 906 + } 907 + } 908 + }; 909 + 910 + ws.onclose = () => { 911 + console.log("WS closed, reconnecting in " + reconnectDelay + "ms"); 912 + setTimeout(() => { 913 + reconnectDelay = Math.min(reconnectDelay * 2, 30000); 914 + connect(); 915 + }, reconnectDelay); 916 + }; 917 + } 918 + 919 + connect(); 920 + 921 + function appendChat(chat) { 922 + const container = document.getElementById("chat-messages"); 923 + const div = document.createElement("div"); 924 + div.className = 925 + "chat-msg" + (chat.type === "system_event" ? " system" : ""); 926 + 927 + let ts = ""; 928 + if (chat.ts) { 929 + const d = new Date(chat.ts); 930 + ts = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); 931 + } 932 + 933 + div.innerHTML = 934 + '<span class="user">' + 935 + esc(chat.user || "anonymous") + 936 + "</span>" + 937 + '<span class="time">' + 938 + ts + 939 + "</span><br>" + 940 + '<span class="text">' + 941 + esc(chat.content || "") + 942 + "</span>"; 943 + container.appendChild(div); 944 + 945 + // Auto-scroll only if user is near the bottom (within 30px threshold). 946 + const threshold = 30; 947 + if ( 948 + container.scrollHeight - 949 + container.scrollTop - 950 + container.clientHeight < 951 + threshold 952 + ) { 953 + container.scrollTop = container.scrollHeight; 954 + } 955 + } 956 + 957 + let elapsedInterval = null; 958 + 959 + function updateNowPlaying(state) { 960 + const el = document.getElementById("now-playing"); 961 + if (!el) return; 962 + 963 + // Clear old interval when track changes. 964 + if (elapsedInterval) { 965 + clearInterval(elapsedInterval); 966 + elapsedInterval = null; 967 + } 968 + 969 + if (state.current_track) { 970 + const trackId = state.current_track.id; 971 + 972 + // Detect track change — set awaitingInit so the next video object 973 + // is treated as the new init segment. 974 + if (currentTrackId !== null && currentTrackId !== trackId) { 975 + console.log( 976 + "Track change detected:", 977 + currentTrackId, 978 + "->", 979 + trackId, 980 + "setting awaitingInit=true", 981 + ); 982 + awaitingInit = true; 983 + pendingBoxes.length = 0; 984 + seekInitDone = false; 985 + } 986 + console.log( 987 + "updateNowPlaying: trackId=", 988 + trackId, 989 + "currentTrackId=", 990 + currentTrackId, 991 + "awaitingInit=", 992 + awaitingInit, 993 + ); 994 + currentTrackId = trackId; 995 + 996 + const startedAt = state.current_track.started_at; 997 + const sourceUrl = state.current_track.url || ""; 998 + const sourceHtml = sourceUrl 999 + ? '<a href="' + 1000 + safeUrl(sourceUrl) + 1001 + '" target="_blank" class="source-link" style="font-size:0.85em">source</a>' 1002 + : ""; 1003 + 1004 + el.innerHTML = 1005 + "<strong>Playing:</strong> " + 1006 + esc(state.current_track.title) + 1007 + ' <span id="elapsed">' + 1008 + formatTime((Date.now() - startedAt) / 1000) + 1009 + "</span>" + 1010 + " / " + 1011 + esc(state.current_track.duration) + 1012 + " " + 1013 + sourceHtml + 1014 + ' <span style="color:#6c7086">(' + 1015 + state.clients + 1016 + " viewers)</span>"; 1017 + 1018 + // Update elapsed every second from local clock. 1019 + elapsedInterval = setInterval(() => { 1020 + const elSpan = document.getElementById("elapsed"); 1021 + if (elSpan) { 1022 + const elapsed = (Date.now() - startedAt) / 1000; 1023 + elSpan.textContent = formatTime(elapsed); 1024 + } 1025 + }, 1000); 1026 + 1027 + // Always-latest sync: seek video if difference > 3 seconds. 1028 + const video = document.getElementById("video-player"); 1029 + if (video && video.buffered.length > 0 && startedAt) { 1030 + const target = (Date.now() - startedAt) / 1000; 1031 + if (target > 0) { 1032 + const diff = Math.abs(video.currentTime - target); 1033 + if (diff > 3) { 1034 + video.currentTime = target; 1035 + } 1036 + } 1037 + } 1038 + } else { 1039 + currentTrackId = null; 1040 + el.innerHTML = 1041 + '<span style="color:#6c7086">Waiting for tracks...</span>'; 1042 + } 1043 + 1044 + // Update queue section from state. 1045 + updateQueueDisplay(state); 1046 + } 1047 + 1048 + function updateQueueDisplay(state) { 1049 + const qSection = document.getElementById("queue-section"); 1050 + const hSection = document.getElementById("history-section"); 1051 + if (!qSection) return; 1052 + 1053 + if (state.queue && state.queue.length > 0) { 1054 + let html = '<ul class="queue">'; 1055 + for (const item of state.queue) { 1056 + const thumb = item.thumbnail 1057 + ? '<img src="' + 1058 + esc(item.thumbnail) + 1059 + '" class="thumb-img" alt="">' 1060 + : '<div class="thumb"></div>'; 1061 + html += 1062 + "<li>" + 1063 + thumb + 1064 + '<div class="title">' + 1065 + esc(item.title) + 1066 + "</div>" + 1067 + '<div class="duration">' + 1068 + esc(item.duration) + 1069 + "</div>" + 1070 + '<a href="' + 1071 + safeUrl(item.url) + 1072 + '" target="_blank" class="source-link" rel="noopener noreferrer">source</a>' + 1073 + "</li>"; 1074 + } 1075 + html += "</ul>"; 1076 + qSection.innerHTML = html; 1077 + } else if (state.current_track) { 1078 + qSection.innerHTML = 1079 + '<div class="empty">No upcoming tracks. Add another link to the queue.</div>'; 1080 + } else { 1081 + qSection.innerHTML = 1082 + '<div class="empty">Queue is empty. Paste a link above to get started.</div>'; 1083 + } 1084 + 1085 + if (hSection && state.history && state.history.length > 0) { 1086 + let html = 1087 + '<div class="history-header">Played</div><ul class="queue">'; 1088 + for (const item of state.history) { 1089 + const thumb = item.thumbnail 1090 + ? '<img src="' + 1091 + esc(item.thumbnail) + 1092 + '" class="thumb-img" alt="">' 1093 + : '<div class="thumb"></div>'; 1094 + html += 1095 + '<li style="opacity:0.7">' + 1096 + thumb + 1097 + '<div class="title">' + 1098 + esc(item.title) + 1099 + "</div>" + 1100 + '<div class="duration">' + 1101 + esc(item.duration) + 1102 + "</div>" + 1103 + '<a href="' + 1104 + safeUrl(item.url) + 1105 + '" target="_blank" class="source-link" rel="noopener noreferrer">source</a>' + 1106 + "</li>"; 1107 + } 1108 + html += "</ul>"; 1109 + hSection.innerHTML = html; 1110 + } else if (hSection) { 1111 + hSection.innerHTML = ""; 1112 + } 1113 + } 1114 + 1115 + function formatTime(secs) { 1116 + const m = Math.floor(secs / 60); 1117 + const s = Math.floor(secs % 60); 1118 + return m + ":" + (s < 10 ? "0" : "") + s; 1119 + } 1120 + 1121 + function esc(s) { 1122 + const d = document.createElement("div"); 1123 + d.textContent = s; 1124 + return d.innerHTML; 1125 + } 1126 + 1127 + function safeUrl(url) { 1128 + if (!url) return ""; 1129 + // Only allow http:// and https:// URLs. 1130 + if (url.startsWith("http://") || url.startsWith("https://")) { 1131 + return esc(url); 1132 + } 1133 + return ""; // Reject all other URL schemes (javascript:, data:, file:, etc.) 1134 + } 1135 + 1136 + // Send chat 1137 + document.getElementById("chat-send").addEventListener("click", sendChat); 1138 + document.getElementById("chat-input").addEventListener("keydown", (e) => { 1139 + if (e.key === "Enter") sendChat(); 1140 + }); 1141 + 1142 + function sendChat() { 1143 + const input = document.getElementById("chat-input"); 1144 + const content = input.value.trim(); 1145 + if (!content) return; 1146 + const payload = new TextEncoder().encode(JSON.stringify({ content })); 1147 + const obj = concat([ 1148 + encVarint(0x00), 1149 + encVarint(2), // track_id = Chat 1150 + encVarint(0), // group_id 1151 + encVarint(0), // object_id 1152 + payload, 1153 + ]); 1154 + ws.send(obj); 1155 + input.value = ""; 1156 + } 1157 + 1158 + // ── Ingest helper ─────────────────────────────────────────────── 1159 + async function ingest() { 1160 + const input = document.getElementById("url-input"); 1161 + const resp = await fetch("/api/ingest", { 1162 + method: "POST", 1163 + headers: { "Content-Type": "application/json" }, 1164 + body: JSON.stringify({ url: input.value, room_id: roomId }), 1165 + }); 1166 + if (resp.ok) { 1167 + input.value = ""; 1168 + // Queue will update via state track broadcast — no reload needed. 1169 + } else { 1170 + const err = await resp.json(); 1171 + alert(err.error || "ingest failed"); 1172 + } 1173 + } 1174 + 1175 + // ── Skip current track ────────────────────────────────────────── 1176 + async function skip() { 1177 + await fetch("/api/skip", { 1178 + method: "POST", 1179 + headers: { "Content-Type": "application/json" }, 1180 + body: JSON.stringify({ room_id: roomId }), 1181 + }); 1182 + } 1183 + </script> 1184 + </body> 1185 + </html>