This commit is contained in:
2026-07-31 20:20:48 +01:00
parent d4b77bd717
commit c1435a70fb
12 changed files with 1009 additions and 168 deletions
Generated
+141 -9
View File
@@ -618,7 +618,7 @@ dependencies = [
"num-traits", "num-traits",
"serde", "serde",
"wasm-bindgen", "wasm-bindgen",
"windows-link", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -1481,7 +1481,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core", "windows-core 0.62.2",
] ]
[[package]] [[package]]
@@ -1857,6 +1857,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "ntapi"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1936,6 +1945,25 @@ dependencies = [
"libm", "libm",
] ]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.1",
]
[[package]]
name = "objc2-io-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
dependencies = [
"libc",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "oid-registry" name = "oid-registry"
version = "0.8.1" version = "0.8.1"
@@ -2031,7 +2059,7 @@ dependencies = [
"libc", "libc",
"redox_syscall 0.5.18", "redox_syscall 0.5.18",
"smallvec", "smallvec",
"windows-link", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -2900,6 +2928,7 @@ dependencies = [
"serde_json", "serde_json",
"str0m", "str0m",
"symphonia", "symphonia",
"sysinfo",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tower-http", "tower-http",
@@ -3515,6 +3544,20 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "sysinfo"
version = "0.36.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows",
]
[[package]] [[package]]
name = "tap" name = "tap"
version = "1.0.1" version = "1.0.1"
@@ -4050,6 +4093,41 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "windows"
version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections",
"windows-core 0.61.2",
"windows-future",
"windows-link 0.1.3",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -4058,9 +4136,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [ dependencies = [
"windows-implement", "windows-implement",
"windows-interface", "windows-interface",
"windows-link", "windows-link 0.2.1",
"windows-result", "windows-result 0.4.1",
"windows-strings", "windows-strings 0.5.1",
]
[[package]]
name = "windows-future"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading",
] ]
[[package]] [[package]]
@@ -4085,19 +4174,53 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "windows-link"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [
"windows-link 0.1.3",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [ dependencies = [
"windows-link", "windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [
"windows-link 0.1.3",
] ]
[[package]] [[package]]
@@ -4106,7 +4229,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [ dependencies = [
"windows-link", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -4133,7 +4256,7 @@ version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [ dependencies = [
"windows-link", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -4167,6 +4290,15 @@ dependencies = [
"windows_x86_64_msvc 0.52.6", "windows_x86_64_msvc 0.52.6",
] ]
[[package]]
name = "windows-threading"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
dependencies = [
"windows-link 0.1.3",
]
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.48.5" version = "0.48.5"
+1
View File
@@ -37,4 +37,5 @@ rubato = "3.0.0"
chrono = "0.4.45" chrono = "0.4.45"
regex = "1" regex = "1"
thiserror = "2.0.18" thiserror = "2.0.18"
sysinfo = "0.36"
axum-extra = { version = "0.12.6", features = ["cookie"] } axum-extra = { version = "0.12.6", features = ["cookie"] }
+2 -3
View File
@@ -95,14 +95,13 @@ impl Av1CodecParser {
self.first_coded_frame = false; self.first_coded_frame = false;
if is_keyframe { if is_keyframe
if let Some(config) = &self.config_obus { && let Some(config) = &self.config_obus {
let mut out = Vec::with_capacity(config.len() + payload.len()); let mut out = Vec::with_capacity(config.len() + payload.len());
out.extend_from_slice(config); out.extend_from_slice(config);
out.extend_from_slice(payload); out.extend_from_slice(payload);
return Some((out, true)); return Some((out, true));
} }
}
Some((payload.to_vec(), is_keyframe)) Some((payload.to_vec(), is_keyframe))
} }
+2 -3
View File
@@ -148,14 +148,13 @@ impl H264CodecParser {
// Prepend SPS+PPS before every keyframe so str0m's packetizer // Prepend SPS+PPS before every keyframe so str0m's packetizer
// can bundle them into a STAP-A alongside the IDR NALU. // can bundle them into a STAP-A alongside the IDR NALU.
if is_keyframe { if is_keyframe
if let (Some(sps), Some(pps)) = (&self.sps, &self.pps) { && let (Some(sps), Some(pps)) = (&self.sps, &self.pps) {
out.extend_from_slice(&[0, 0, 0, 1]); out.extend_from_slice(&[0, 0, 0, 1]);
out.extend_from_slice(sps); out.extend_from_slice(sps);
out.extend_from_slice(&[0, 0, 0, 1]); out.extend_from_slice(&[0, 0, 0, 1]);
out.extend_from_slice(pps); out.extend_from_slice(pps);
} }
}
// Convert each length-prefixed NALU to an Annex B start-code NALU. // Convert each length-prefixed NALU to an Annex B start-code NALU.
let mut i = 0; let mut i = 0;
+2 -3
View File
@@ -74,8 +74,8 @@ impl H265CodecParser {
fn hvcc_to_annexb(&self, payload: &[u8], is_keyframe: bool) -> Option<Vec<u8>> { fn hvcc_to_annexb(&self, payload: &[u8], is_keyframe: bool) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(payload.len()); let mut out = Vec::with_capacity(payload.len());
if is_keyframe { if is_keyframe
if let (Some(vps), Some(sps), Some(pps)) = (&self.vps, &self.sps, &self.pps) { && let (Some(vps), Some(sps), Some(pps)) = (&self.vps, &self.sps, &self.pps) {
out.extend_from_slice(&[0, 0, 0, 1]); out.extend_from_slice(&[0, 0, 0, 1]);
out.extend_from_slice(vps); out.extend_from_slice(vps);
out.extend_from_slice(&[0, 0, 0, 1]); out.extend_from_slice(&[0, 0, 0, 1]);
@@ -83,7 +83,6 @@ impl H265CodecParser {
out.extend_from_slice(&[0, 0, 0, 1]); out.extend_from_slice(&[0, 0, 0, 1]);
out.extend_from_slice(pps); out.extend_from_slice(pps);
} }
}
let mut i = 0; let mut i = 0;
while i + 4 <= payload.len() { while i + 4 <= payload.len() {
+109 -13
View File
@@ -19,28 +19,25 @@ static KEY_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[A-Za-z0-9 _-]{1
use axum::{ use axum::{
Json, Router, Json, Router,
body::{Body, Bytes}, extract::{FromRequestParts, Path, State},
extract::{Form, FromRequestParts, Path, Query, State},
http::{ http::{
HeaderMap, HeaderName, Method, StatusCode, HeaderName, Method, StatusCode,
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, SET_COOKIE}, header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
request::Parts, request::Parts,
}, },
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{get, post}, routing::{delete, get, post},
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use entity::{auth_session, stream_key, stream_session, users}; use entity::{auth_session, stream_key, stream_session, users};
use sea_orm::{ use sea_orm::{DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter};
DatabaseConnection, EntityTrait, IntoActiveModel, QueryFilter, prelude::DateTimeUtc,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sysinfo::System;
use tokio::{ use tokio::{
net::TcpListener, net::TcpListener,
sync::{Mutex, mpsc::Sender}, sync::{Mutex, mpsc::Sender},
}; };
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use uuid::Uuid;
use tracing::{debug, info, warn}; use tracing::{debug, info, warn};
@@ -48,7 +45,7 @@ use crate::{
AppState, AppState,
hash::{hash_password, verify_password}, hash::{hash_password, verify_password},
http_error::HttpError, http_error::HttpError,
webrtc_ingest::handle_whip_injest, webrtc_ingest::{handle_whip_injest, handle_whip_injest_delete, handle_whip_injest_patch},
}; };
const MAX_USERNAME_LEN: usize = 32; const MAX_USERNAME_LEN: usize = 32;
@@ -58,6 +55,11 @@ pub struct HttpServerConfig {
pub signup_code: String, pub signup_code: String,
} }
pub struct ServerInfo {
pub version: &'static str,
pub start_time: std::time::Instant,
}
pub struct HttpServer { pub struct HttpServer {
pub offer_tx: Sender<(i32, i32, String)>, pub offer_tx: Sender<(i32, i32, String)>,
pub accept_rx: async_broadcast::InactiveReceiver<(i32, Option<String>)>, pub accept_rx: async_broadcast::InactiveReceiver<(i32, Option<String>)>,
@@ -65,6 +67,7 @@ pub struct HttpServer {
pub request_count: AtomicI32, pub request_count: AtomicI32,
pub db: DatabaseConnection, pub db: DatabaseConnection,
pub config: Arc<HttpServerConfig>, pub config: Arc<HttpServerConfig>,
pub info: Arc<ServerInfo>,
} }
impl HttpServer { impl HttpServer {
@@ -77,7 +80,7 @@ impl HttpServer {
]; ];
let cors = CorsLayer::new() let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::OPTIONS]) .allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE, Method::OPTIONS])
.allow_headers([ .allow_headers([
AUTHORIZATION, AUTHORIZATION,
ACCEPT, ACCEPT,
@@ -97,16 +100,28 @@ impl HttpServer {
.patch(edit_stream_key), .patch(edit_stream_key),
) )
// .route("/api/admin/server_stats", get(todo!())) // .route("/api/admin/server_stats", get(todo!()))
.route("/api/whip", post(handle_whip_injest)) // .route("/api/whip", post(handle_whip_injest))
.route("/api/login", post(login_handler)) .route("/api/login", post(login_handler))
.route("/api/stream/{slug}", post(stream_handler)) .route("/api/stream/{slug}", post(stream_handler))
.route("/stream", post(handle_whip_injest))
.route("/stream/{slug}", delete(handle_whip_injest_delete).patch(handle_whip_injest_patch))
.route("/api/whip/{slug}", delete(handle_whip_injest_delete).patch(handle_whip_injest_patch))
.route("/api/meow", get(meow_handler)) .route("/api/meow", get(meow_handler))
.route("/api/health", get(health_handler))
.route("/api/uptime", get(uptime_handler))
.route("/api/version", get(version_handler))
.route("/api/stats", get(stats_handler))
.layer(cors) .layer(cors)
.with_state(state); .with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], 3000)); let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
let listener = TcpListener::bind(addr).await.unwrap(); let listener = TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap(); axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.unwrap();
} }
} }
@@ -336,6 +351,7 @@ fn cookie_for_token(token: &str, dev: bool) -> Cookie<'static> {
false => Cookie::build(("session", token)) false => Cookie::build(("session", token))
.path("/") .path("/")
.http_only(true) .http_only(true)
// .max_age(Duration::from_hours(999999))
.same_site(axum_extra::extract::cookie::SameSite::Lax) .same_site(axum_extra::extract::cookie::SameSite::Lax)
.build(), .build(),
} }
@@ -488,3 +504,83 @@ async fn stream_handler(
async fn meow_handler() -> &'static str { async fn meow_handler() -> &'static str {
"meow" "meow"
} }
#[derive(Serialize)]
struct HealthResponse {
status: &'static str,
uptime_seconds: u64,
version: &'static str,
}
async fn health_handler(
State(state): State<Arc<HttpServer>>,
) -> Result<impl IntoResponse, HttpError> {
let uptime = state.info.start_time.elapsed().as_secs();
Ok((
StatusCode::OK,
Json(HealthResponse {
status: "ok",
uptime_seconds: uptime,
version: state.info.version,
}),
))
}
#[derive(Serialize)]
struct UptimeResponse {
uptime_seconds: u64,
}
async fn uptime_handler(
State(state): State<Arc<HttpServer>>,
) -> Result<impl IntoResponse, HttpError> {
let uptime = state.info.start_time.elapsed().as_secs();
Ok((
StatusCode::OK,
Json(UptimeResponse {
uptime_seconds: uptime,
}),
))
}
async fn version_handler(
State(state): State<Arc<HttpServer>>,
) -> Result<impl IntoResponse, HttpError> {
Ok((
StatusCode::OK,
Json(serde_json::json!({ "version": state.info.version })),
))
}
#[derive(Serialize)]
struct StatsResponse {
version: &'static str,
uptime_seconds: u64,
cpu_usage_percent: f32,
active_streams: usize,
request_count: i32,
}
async fn stats_handler(
State(state): State<Arc<HttpServer>>,
) -> Result<impl IntoResponse, HttpError> {
let uptime = state.info.start_time.elapsed().as_secs();
let mut system = System::new();
system.refresh_cpu_all();
let cpu_usage = system.global_cpu_usage();
let active_streams = {
let app = state.appstate.lock().await;
app.stream_sessions.len()
};
let request_count = state.request_count.load(Ordering::Relaxed);
Ok((
StatusCode::OK,
Json(StatsResponse {
version: state.info.version,
uptime_seconds: uptime,
cpu_usage_percent: cpu_usage,
active_streams,
request_count,
}),
))
}
+36 -23
View File
@@ -1,36 +1,32 @@
use ::chrono::{DateTime, Utc}; use ::chrono::{DateTime, Utc};
use std::{env, error::Error, sync::Arc}; use std::{
env,
error::Error,
sync::{Arc, atomic::AtomicU32},
};
use tracing::{info, warn}; use tracing::{info, warn};
use async_broadcast::broadcast; use async_broadcast::broadcast;
use dashmap::DashMap; use dashmap::DashMap;
use entity::stream_session; use entity::stream_session;
use migration::{Migrator, MigratorTrait}; use migration::{Migrator, MigratorTrait};
use rml_rtmp::{ use sea_orm::Database;
handshake::{Handshake, HandshakeProcessResult, PeerType},
sessions::{ServerSession, ServerSessionConfig, ServerSessionEvent, ServerSessionResult},
};
use sea_orm::{
Database, IntoActiveModel,
sqlx::types::chrono::{self, Local},
};
use tokio::{ use tokio::{
fs,
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener, net::TcpListener,
sync::Mutex, sync::Mutex,
task::JoinSet, task::JoinSet,
time::Instant,
}; };
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use crate::{ use crate::{
audio::OpusAudioFrame, audio::OpusAudioFrame,
codec::VideoFrame, codec::VideoFrame,
http::{HttpServer, HttpServerConfig}, http::{HttpServer, HttpServerConfig, ServerInfo},
webrtc_proxy::WebRtcProxyConfig, webrtc_proxy::{WebRtcProxyConfig, WebrtcProxy},
}; };
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
mod audio; mod audio;
mod codec; mod codec;
mod hash; mod hash;
@@ -44,6 +40,7 @@ mod webrtc_proxy;
// #[derive(Debug)] // #[derive(Debug)]
pub struct AppState { pub struct AppState {
pub stream_sessions: Arc<DashMap<i32, StreamSession>>, pub stream_sessions: Arc<DashMap<i32, StreamSession>>,
pub webrtc_proxy: WebrtcProxy,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -53,14 +50,24 @@ pub enum StreamCodec {
AV1, AV1,
} }
pub struct StreamSession { pub struct StreamSession {
pub stream_key_id: i32, pub stream_key_id: i32,
pub stream_key_label: String, pub stream_key_label: String,
pub frame_channel: async_broadcast::Sender<Arc<VideoFrame>>, pub frame_channel: async_broadcast::Sender<Arc<VideoFrame>>,
pub audio_channel: async_broadcast::Sender<Arc<OpusAudioFrame>>, pub audio_channel: async_broadcast::Sender<Arc<OpusAudioFrame>>,
pub codec: Option<StreamCodec>, pub codec: Option<StreamCodec>,
// // ---- WHIP-negotiated video codec details (populated by whip ingest) ----
/// Payload type from WHIP SDP negotiation.
pub video_pt: Option<u8>,
/// H.264 profile_level_id from WHIP SDP negotiation (e.g. 0x42e01f for CBP).
/// When set alongside `video_pt`, the WHEP answer only advertises this exact
/// profile instead of all three H.264 profiles.
pub video_profile_level_id: Option<u32>,
// ----
pub started_at: DateTime<Utc>, pub started_at: DateTime<Utc>,
pub active_clients: AtomicU32,
} }
#[tokio::main] #[tokio::main]
@@ -83,9 +90,17 @@ async fn main() -> Result<(), Box<dyn Error>> {
stream_session::Model::clean_unended_streams(&db) stream_session::Model::clean_unended_streams(&db)
.await .await
.unwrap(); .unwrap();
let proxyconfig = WebRtcProxyConfig {
proxy_port: env::var("RTC_PORT")
.unwrap_or("6969".into())
.parse()
.expect("RTC_PORT needs to be a number (i32)"),
};
let proxy = webrtc_proxy::WebrtcProxy::new(proxyconfig).await.unwrap();
let appstate = Arc::new(Mutex::new(AppState { let appstate = Arc::new(Mutex::new(AppState {
stream_sessions: Arc::new(DashMap::new()), stream_sessions: Arc::new(DashMap::new()),
webrtc_proxy: proxy.clone(),
})); }));
let (offer_tx, offer_rx) = tokio::sync::mpsc::channel::<(i32, i32, String)>(64); let (offer_tx, offer_rx) = tokio::sync::mpsc::channel::<(i32, i32, String)>(64);
// Request_Id, // Request_Id,
@@ -109,14 +124,11 @@ async fn main() -> Result<(), Box<dyn Error>> {
}), }),
} }
.into(), .into(),
info: Arc::new(ServerInfo {
version: SERVER_VERSION,
start_time: std::time::Instant::now(),
}),
}; };
let proxyconfig = WebRtcProxyConfig {
proxy_port: env::var("RTC_PORT")
.unwrap_or("6969".into())
.parse()
.expect("RTC_PORT needs to be a number (i32)"),
};
let proxy = webrtc_proxy::WebrtcProxy::new(proxyconfig).await.unwrap();
let app = appstate.lock().await; let app = appstate.lock().await;
let webrtc = webrtc::Webrtc { let webrtc = webrtc::Webrtc {
@@ -124,7 +136,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
accept_tx: answer_tx, accept_tx: answer_tx,
sessions_ref: app.stream_sessions.clone(), sessions_ref: app.stream_sessions.clone(),
db: db.clone(), db: db.clone(),
proxy: proxy.clone().into(), proxy: proxy.clone().into(), // The entire of the proxy's internal are within an arc, cheap
// to clone
}; };
let rtmp = rtmp::Rtmp { let rtmp = rtmp::Rtmp {
+32 -15
View File
@@ -34,14 +34,13 @@ pub struct Rtmp {
async fn write_outbound(socket: &mut TcpStream, results: Vec<ServerSessionResult>) { async fn write_outbound(socket: &mut TcpStream, results: Vec<ServerSessionResult>) {
for r in results { for r in results {
if let ServerSessionResult::OutboundResponse(p) = r { if let ServerSessionResult::OutboundResponse(p) = r
if let Err(e) = socket.write_all(&p.bytes).await { && let Err(e) = socket.write_all(&p.bytes).await {
warn!("RTMP write error: {e}"); warn!("RTMP write error: {e}");
return; return;
} }
} }
} }
}
impl Rtmp { impl Rtmp {
fn parse_video_codec(payload: &[u8]) -> Result<StreamCodec, Box<dyn Error + Send + Sync>> { fn parse_video_codec(payload: &[u8]) -> Result<StreamCodec, Box<dyn Error + Send + Sync>> {
@@ -96,8 +95,8 @@ impl Rtmp {
x => return Err(format!("unexpected handshake state: {:?}", x).into()), x => return Err(format!("unexpected handshake state: {:?}", x).into()),
} }
let (rtmp_session, init_bytes) = let (rtmp_session, init_bytes) = ServerSession::new(ServerSessionConfig::new())
ServerSession::new(ServerSessionConfig::new()).map_err(|e| format!("ServerSession::new failed: {e}"))?; .map_err(|e| format!("ServerSession::new failed: {e}"))?;
write_outbound(&mut socket, init_bytes).await; write_outbound(&mut socket, init_bytes).await;
Ok((rtmp_session, socket)) Ok((rtmp_session, socket))
@@ -127,8 +126,8 @@ impl Rtmp {
} }
}; };
info!(%peer_addr, "RTMP handshake complete"); info!(%peer_addr, "RTMP handshake complete");
let (mut video_tx, mut video_rx) = broadcast::<Arc<VideoFrame>>(32); let (mut video_tx, video_rx) = broadcast::<Arc<VideoFrame>>(32);
let (mut audio_tx, mut audio_rx) = broadcast::<Arc<OpusAudioFrame>>(32); let (mut audio_tx, audio_rx) = broadcast::<Arc<OpusAudioFrame>>(32);
// video_rx.cycle // video_rx.cycle
video_tx.set_overflow(true); video_tx.set_overflow(true);
@@ -215,7 +214,9 @@ impl Rtmp {
let reply = match session.accept_request(request_id) { let reply = match session.accept_request(request_id) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
warn!("Failed to accept connection request {request_id}: {e}"); warn!(
"Failed to accept connection request {request_id}: {e}"
);
return; return;
} }
}; };
@@ -235,7 +236,11 @@ impl Rtmp {
key key
} else { } else {
warn!(stream_key = %stream_key, "stream key not found, rejecting"); warn!(stream_key = %stream_key, "stream key not found, rejecting");
let reply = match session.reject_request(request_id, "", "Stream key invalid") { let reply = match session.reject_request(
request_id,
"",
"Stream key invalid",
) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
warn!("Failed to reject stream key request: {e}"); warn!("Failed to reject stream key request: {e}");
@@ -255,10 +260,16 @@ impl Rtmp {
Ok(s) => s, Ok(s) => s,
Err(e) => { Err(e) => {
warn!("DB error checking active stream: {e}"); warn!("DB error checking active stream: {e}");
let reply = match session.reject_request(request_id, "", "Internal error") { let reply = match session.reject_request(
request_id,
"",
"Internal error",
) {
Ok(r) => r, Ok(r) => r,
Err(e2) => { Err(e2) => {
warn!("Failed to reject request on DB error: {e2}"); warn!(
"Failed to reject request on DB error: {e2}"
);
return; return;
} }
}; };
@@ -294,6 +305,9 @@ impl Rtmp {
audio_channel: audio_tx.clone(), audio_channel: audio_tx.clone(),
codec: None, codec: None,
started_at: Utc::now(), started_at: Utc::now(),
active_clients: 0.into(),
video_pt: None,
video_profile_level_id: None,
}, },
); );
@@ -312,7 +326,11 @@ impl Rtmp {
.await .await
{ {
warn!("Failed to create stream session record: {e}"); warn!("Failed to create stream session record: {e}");
let _ = session.reject_request(request_id, "", "Internal error"); let _ = session.reject_request(
request_id,
"",
"Internal error",
);
return; return;
} }
stream_id = Some(key.id); stream_id = Some(key.id);
@@ -343,13 +361,12 @@ impl Rtmp {
} => match Self::parse_video_codec(&data) { } => match Self::parse_video_codec(&data) {
Ok(codec) => { Ok(codec) => {
if !codec_stamped { if !codec_stamped {
if let Some(id) = current_stream_key_id { if let Some(id) = current_stream_key_id
if let Some(mut session) = && let Some(mut session) =
stream_sessions.get_mut(&id) stream_sessions.get_mut(&id)
{ {
session.codec = Some(codec.clone()); session.codec = Some(codec.clone());
} }
}
codec_stamped = true; codec_stamped = true;
} }
match codec { match codec {
+69 -44
View File
@@ -59,39 +59,68 @@ impl Webrtc {
let local_addr = self.proxy.public_addr(); let local_addr = self.proxy.public_addr();
let stream_codec = self let session = self.sessions_ref.get(&stream_id);
.sessions_ref let stream_codec = session.as_ref().and_then(|s| s.codec.clone());
.get(&stream_id) let video_pt = session.as_ref().and_then(|s| s.video_pt);
.and_then(|s| s.codec.clone()); let video_profile = session.as_ref().and_then(|s| s.video_profile_level_id);
drop(session);
info!(request_id, stream_id, codec = ?match &stream_codec { info!(request_id, stream_id, codec = ?match &stream_codec {
Some(StreamCodec::H264) => "H264", Some(StreamCodec::H264) => "H264",
Some(StreamCodec::H265) => "H265", Some(StreamCodec::H265) => "H265",
Some(StreamCodec::AV1) => "AV1", Some(StreamCodec::AV1) => "AV1",
None => "unknown", None => "unknown",
}, "configuring RTC codec"); }, video_pt, video_profile, "configuring RTC codec");
let mut builder = Rtc::builder(); let mut builder = Rtc::builder();
{ {
use str0m::format::{Codec, FormatParams};
use str0m::media::Frequency;
let cc = builder.codec_config(); let cc = builder.codec_config();
cc.clear(); cc.clear();
cc.enable_opus(true); cc.enable_opus(true);
match stream_codec {
Some(StreamCodec::H265) => { // When video_pt is set (WHIP-negotiated), enable only that
// exact codec+PT+profile. Otherwise fall back to enabling all
// common profiles for the codec family (RTMP ingest doesn't
// carry profile info, so we cast a wide net for browser compat).
match (stream_codec.clone(), video_pt, video_profile) {
// --- Specific PT + profile (WHIP path) ---
(Some(StreamCodec::H264), Some(pt), Some(profile)) => {
cc.add_h264(pt.into(), None, true, profile);
}
(Some(StreamCodec::H264), Some(pt), None) => {
// PT known, profile unknown — default to Constrained
// Baseline (works everywhere but may mismatch for
// Main/High streams from OBS).
cc.add_h264(pt.into(), None, true, 0x42e01f);
}
(Some(StreamCodec::H265), Some(pt), _) => {
cc.add_h265(pt.into(), None, 1, 0, 180);
}
(Some(StreamCodec::AV1), Some(pt), _) => {
cc.add_config(
pt.into(),
None,
Codec::Av1,
Frequency::NINETY_KHZ,
None,
FormatParams::default(),
);
}
// --- Broad codec family (RTMP path, no WHIP info) ---
(Some(StreamCodec::H265), None, _) => {
cc.enable_h265(true); cc.enable_h265(true);
} }
Some(StreamCodec::AV1) => { (Some(StreamCodec::AV1), None, _) => {
cc.enable_av1(true); cc.enable_av1(true);
} }
_ => { _ => {
// Advertise the common H.264 profiles. str0m matches an cc.add_h264(102.into(), None, true, 0x42e01f);
// incoming offer's payload by profile-level-id, so we must cc.add_h264(104.into(), None, true, 0x4d001f);
// list every profile a browser might offer or negotiation cc.add_h264(106.into(), None, true, 0x64001f);
// fails and the m=video line comes back with an empty PT
// list. Firefox in particular only ever offers Constrained
// Baseline (0x42e01f) — without it Firefox sees no codec.
cc.add_h264(102.into(), None, true, 0x42e01f); // Constrained Baseline
cc.add_h264(104.into(), None, true, 0x4d001f); // Main
cc.add_h264(106.into(), None, true, 0x64001f); // High
} }
} }
} }
@@ -246,11 +275,12 @@ impl Webrtc {
warn!(stream_id, mid = ?ma.mid, "no writer for video mid"); warn!(stream_id, mid = ?ma.mid, "no writer for video mid");
} }
} }
if ma.kind == MediaKind::Audio { if ma.kind == MediaKind::Audio
if let Some(writer) = rtc.writer(ma.mid) { && let Some(writer) = rtc.writer(ma.mid)
let best = writer.payload_params().max_by_key(|p| { {
p.spec().format.profile_level_id.unwrap_or(0) let best = writer
}); .payload_params()
.max_by_key(|p| p.spec().format.profile_level_id.unwrap_or(0));
if let Some(params) = best { if let Some(params) = best {
info!(stream_id, pt = ?params.pt(), "selected audio PT"); info!(stream_id, pt = ?params.pt(), "selected audio PT");
audio_pt = Some(params.pt()); audio_pt = Some(params.pt());
@@ -258,7 +288,6 @@ impl Webrtc {
} }
} }
} }
}
Event::IceConnectionStateChange(state) => { Event::IceConnectionStateChange(state) => {
use str0m::IceConnectionState; use str0m::IceConnectionState;
info!("ICE state: {:?}", state); info!("ICE state: {:?}", state);
@@ -292,29 +321,27 @@ impl Webrtc {
); );
} }
} }
if let Some(video) = &video_stream { if let Some(video) = &video_stream
if video.is_closed() { && video.is_closed()
if let Some(session) = sessions_ref.get(&stream_id) { && let Some(session) = sessions_ref.get(&stream_id)
{
video_stream = Some(session.frame_channel.new_receiver()); video_stream = Some(session.frame_channel.new_receiver());
debug!(stream_id, "subscribed to video channel"); debug!(stream_id, "subscribed to video channel");
} }
} if audio_stream.is_none()
} && let Some(session) = sessions_ref.get(&stream_id)
if audio_stream.is_none() { {
if let Some(session) = sessions_ref.get(&stream_id) {
audio_stream = Some(session.audio_channel.new_receiver()); audio_stream = Some(session.audio_channel.new_receiver());
debug!(stream_id, "subscribed to audio channel"); debug!(stream_id, "subscribed to audio channel");
} }
} if let Some(audio) = &audio_stream
if let Some(audio) = &audio_stream { && audio.is_closed()
if audio.is_closed() { && let Some(session) = sessions_ref.get(&stream_id)
if let Some(session) = sessions_ref.get(&stream_id) { {
audio_stream = Some(session.audio_channel.new_receiver()); audio_stream = Some(session.audio_channel.new_receiver());
debug!(stream_id, "subscribed to audio channel"); debug!(stream_id, "subscribed to audio channel");
} }
} }
}
}
// Wait for whichever happens first: str0m's own deadline, an incoming UDP // Wait for whichever happens first: str0m's own deadline, an incoming UDP
// packet, or a fresh video/audio frame. Waking directly on frame arrival // packet, or a fresh video/audio frame. Waking directly on frame arrival
@@ -333,9 +360,9 @@ impl Webrtc {
} }
} }
result = rx.recv() => { result = rx.recv() => {
if let Some((data, from)) = result { if let Some((data, from)) = result
if let Ok(contents) = (&data[..]).try_into() { && let Ok(contents) = (&data[..]).try_into()
if let Err(e) = rtc.handle_input(Input::Receive( && let Err(e) = rtc.handle_input(Input::Receive(
Instant::now(), Instant::now(),
Receive { Receive {
proto: Protocol::Udp, proto: Protocol::Udp,
@@ -348,8 +375,6 @@ impl Webrtc {
return; return;
} }
} }
}
}
res = Webrtc::recv_video(&mut video_stream), if video_stream.is_some() => { res = Webrtc::recv_video(&mut video_stream), if video_stream.is_some() => {
match res { match res {
Ok(frame) => { Ok(frame) => {
@@ -475,12 +500,12 @@ impl Webrtc {
) { ) {
let now = Instant::now(); let now = Instant::now();
let rtp_time = MediaTime::new(frame.timestamp_ms as u64 * 48, Frequency::FORTY_EIGHT_KHZ); let rtp_time = MediaTime::new(frame.timestamp_ms as u64 * 48, Frequency::FORTY_EIGHT_KHZ);
if let (Some(pt), Some(writer)) = (audio_pt, audio_mid.and_then(|m| rtc.writer(m))) { if let (Some(pt), Some(writer)) = (audio_pt, audio_mid.and_then(|m| rtc.writer(m)))
if let Err(e) = writer.write(pt, now, rtp_time, frame.data.to_vec()) { && let Err(e) = writer.write(pt, now, rtp_time, frame.data.to_vec())
{
warn!("RTP write error: {:?}", e); warn!("RTP write error: {:?}", e);
} }
} }
}
fn drain_audio( fn drain_audio(
stream: &mut Option<async_broadcast::Receiver<Arc<OpusAudioFrame>>>, stream: &mut Option<async_broadcast::Receiver<Arc<OpusAudioFrame>>>,
+551 -7
View File
@@ -1,17 +1,561 @@
use std::sync::Arc; use std::{net::SocketAddr, sync::Arc, time::Instant};
use axum::{extract::State, response::IntoResponse}; use async_broadcast::broadcast;
use str0m::change::SdpOffer; use axum::{
use tracing::info; extract::{ConnectInfo, Path, State},
http::{HeaderMap, StatusCode, header},
response::IntoResponse,
};
use bytes::Bytes;
use chrono::{Local, Utc};
use dashmap::DashMap;
use entity::{stream_key, stream_session};
use sea_orm::{DatabaseConnection, IntoActiveModel};
use str0m::{
Candidate, Event, Input, Output, Rtc,
change::SdpOffer,
media::{MediaKind, Mid},
net::{Protocol, Receive},
};
use tokio::{net::UdpSocket, sync::mpsc::Receiver};
use tracing::{debug, error, info, warn};
use crate::http::HttpServer; use crate::{
StreamSession, audio::OpusAudioFrame, codec::VideoFrame, http::HttpServer,
http_error::HttpError,
};
pub async fn handle_whip_injest_delete(
State(state): State<Arc<HttpServer>>,
ConnectInfo(remote): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<impl IntoResponse, HttpError> {
let token = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.trim().to_string());
let Some(token) = token else {
warn!("Whip: missing bearer token from {}", remote);
return Err(HttpError::Unauthorized);
};
let key = stream_key::Entity::find_by_key(&state.db, &token)
.await?
.ok_or(HttpError::Unauthorized)?;
let active_session = stream_session::Model::get_active_by_stream_key_id(&state.db, key.id)
.await?
.ok_or(HttpError::NotFound)?;
active_session
.into_active_model()
.finish_stream_session(&state.db, Utc::now())
.await?;
Ok(StatusCode::OK)
}
/// PATCH /api/whip/{id} — Trickle ICE candidate delivery.
pub async fn handle_whip_injest_patch(
State(state): State<Arc<HttpServer>>,
Path(slug): Path<String>,
headers: HeaderMap,
body: String,
) -> Result<impl IntoResponse, HttpError> {
// The slug is the stream_key_id (set by the POST handler's Location header).
let stream_key_id: i32 = slug
.parse()
.map_err(|e| {
warn!(%slug, "Whip PATCH: bad slug: {:?}", e);
HttpError::NotFound
})?;
info!(stream_key_id, ct = ?headers.get(header::CONTENT_TYPE), "Whip PATCH: trickle candidate");
// Look up the trickle sender for this session.
let trickle_map = state
.appstate
.lock()
.await
.webrtc_proxy
.trickle_tx
.clone();
let tx = trickle_map
.get(&stream_key_id)
.ok_or_else(|| {
warn!(stream_key_id, "Whip PATCH: no trickle channel for session");
HttpError::NotFound
})?;
debug!(stream_key_id, %body, "Whip PATCH: forwarding trickle candidate");
tx.send(body).ok();
Ok(StatusCode::NO_CONTENT)
}
pub async fn handle_whip_injest( pub async fn handle_whip_injest(
State(state): State<Arc<HttpServer>>, State(state): State<Arc<HttpServer>>,
ConnectInfo(remote): ConnectInfo<SocketAddr>,
headers: HeaderMap,
offer: String, offer: String,
) -> impl IntoResponse { ) -> axum::response::Response {
let sdp_offer = SdpOffer::from_sdp_string(&offer).unwrap(); let err = |status, msg: &str| -> axum::response::Response {
(
status,
[(header::CONTENT_TYPE, "text/plain")],
msg.to_string(),
)
.into_response()
};
let public_addr = state.appstate.lock().await.webrtc_proxy.public_addr();
let token = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.trim().to_string());
let Some(token) = token else {
warn!("Whip: missing bearer token from {}", remote);
return err(StatusCode::UNAUTHORIZED, "missing bearer token");
};
let key = match stream_key::Entity::find_by_key(&state.db, &token).await {
Ok(Some(key)) => key,
Ok(None) => {
warn!("Whip: stream key not found, rejecting {}", remote);
return err(StatusCode::UNAUTHORIZED, "invalid stream key");
}
Err(e) => {
error!("Whip: DB error looking up stream key: {:?}", e);
return err(StatusCode::INTERNAL_SERVER_ERROR, "db error");
}
};
match stream_session::Model::get_active_by_stream_key_id(&state.db, key.id).await {
Ok(Some(_)) => {
warn!(stream_key_id = key.id, label = %key.label, "Whip: stream key already live, rejecting duplicate publish from {}", remote);
return err(StatusCode::CONFLICT, "stream already live");
}
Ok(None) => {}
Err(e) => {
error!("Whip: DB error checking active stream: {:?}", e);
return err(StatusCode::INTERNAL_SERVER_ERROR, "db error");
}
};
info!(stream_key_id = key.id, label = %key.label, "Whip authenticated stream key from {}", remote);
// Parse the SDP offer first so we can detect the codec and
// configure the Rtc before accepting.
let sdp_offer = match SdpOffer::from_sdp_string(&offer) {
Ok(o) => o,
Err(e) => {
error!("Whip: cant parse offer from {}: {:?}", remote, e);
return err(StatusCode::BAD_REQUEST, "invalid SDP offer");
}
};
info!(
"Whip offer from {} — {} media line(s):",
remote,
sdp_offer.media_lines.len()
);
for x in &sdp_offer.media_lines { for x in &sdp_offer.media_lines {
info!(" {}", x); info!(" {}", x);
} }
// Detect codec from the offer so we can enable matching codecs.
let stream_codec = video_codec_from_sdp_offer(&sdp_offer);
info!(?stream_codec, "detected codec from WHIP offer");
// Build Rtc with ICE-Lite (required by WHIP RFC 9728 §4.1) and
// matching codecs enabled.
// Not using ICE-Lite: full ICE lets the server initiate checks
// when OBS hasn't sent its candidates yet (Trickle ICE without PATCH).
let mut builder = Rtc::builder();
{
let cc = builder.codec_config();
cc.clear();
cc.enable_opus(true);
match &stream_codec {
Some(crate::StreamCodec::H264) => {
info!("Whip: enabling H.264 codec");
cc.enable_h264(true);
}
Some(crate::StreamCodec::H265) => {
info!("Whip: enabling H.265 codec");
cc.enable_h265(true);
}
Some(crate::StreamCodec::AV1) => {
info!("Whip: enabling AV1 codec");
cc.enable_av1(true);
}
None => {
warn!("Whip: no video codec detected in offer, enabling H.264 as fallback");
cc.enable_h264(true);
}
}
}
let mut rtc = builder.build(Instant::now());
let candidate = Candidate::host(public_addr, Protocol::Udp).unwrap();
rtc.add_local_candidate(candidate);
info!(%public_addr, "Whip: added local ICE candidate, accepting offer…");
let offer_answe = match rtc.sdp_api().accept_offer(sdp_offer) {
Ok(a) => a,
Err(e) => {
error!("cant accept inject offer: {:?}", e);
return err(StatusCode::BAD_REQUEST, "could not accept offer");
}
};
// OBS sends no a=candidate: lines (disableAutoGathering). Derive a
// remote host candidate from the HTTP source address so the server
// has somewhere to send STUN checks.
if let Ok(c) = Candidate::host(remote, Protocol::Udp) {
info!(%remote, "Whip: no candidates in offer, adding HTTP-derived remote host candidate");
rtc.add_remote_candidate(c);
}
// Extract negotiated codec, PT, and profile from the answer
// so WHEP viewers can use the exact same codec config.
let (negotiated_codec, video_pt, video_profile) =
extract_negotiated_codec_info(&offer_answe);
info!(
?negotiated_codec,
video_pt,
?video_profile,
"negotiated codec from WHIP answer"
);
if negotiated_codec.is_none() || video_pt.is_none() {
warn!("Whip: no common video codec negotiated, rejecting");
return err(StatusCode::NOT_ACCEPTABLE, "no common video codec");
}
let answer_sdp = offer_answe.to_sdp_string()
// Strip a=ice-options:trickle so OBS starts ICE immediately.
.replace("a=ice-options:trickle\r\n", "")
.replace("a=ice-options:trickle\n", "");
// Fix up the answer for libdatachannel (OBS WHIP):
// 1. Strip a=group:BUNDLE — OBS doesn't negotiate it.
// 2. Add the host candidate to the video m= line.
let answer_sdp = answer_sdp
.replace("a=group:BUNDLE 0 1\r\n", "")
.replace("a=group:BUNDLE 0 1\n", "");
let answer_sdp = if let Some(cand_line) = answer_sdp
.lines()
.find(|l| l.starts_with("a=candidate:"))
{
// Insert the candidate line after the video m= line.
let cand_replacement = format!("m=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 127.0.0.1\r\n{}\r\n", cand_line);
answer_sdp.replace("m=video 9 UDP/TLS/RTP/SAVPF 96\r\nc=IN IP4 0.0.0.0\r\n", &cand_replacement)
} else {
answer_sdp
};
info!("Serving Whip SDP answer to {}:\n{}", remote, answer_sdp);
let ufrag = answer_sdp
.lines()
.find(|l| l.starts_with("a=ice-ufrag:"))
.and_then(|l| l.strip_prefix("a=ice-ufrag:"))
.map(|s| s.trim().to_string());
let Some(ufrag) = ufrag else {
error!("Whip: answer has no a=ice-ufrag");
return err(StatusCode::INTERNAL_SERVER_ERROR, "no ice-ufrag in answer");
};
let ice_pwd = answer_sdp
.lines()
.find(|l| l.starts_with("a=ice-pwd:"))
.and_then(|l| l.strip_prefix("a=ice-pwd:"))
.map(|s| s.trim().to_string());
info!(?ufrag, ?ice_pwd, "Whip: registering ICE credentials with proxy");
let (socket, rx) = state.appstate.lock().await.webrtc_proxy.add_client(ufrag);
let stream_sessions = state.appstate.lock().await.stream_sessions.clone();
let (video_tx, video_rx) = broadcast::<Arc<VideoFrame>>(4);
let (audio_tx, audio_rx) = broadcast::<Arc<OpusAudioFrame>>(4);
stream_sessions.insert(
key.id,
StreamSession {
stream_key_id: key.id,
stream_key_label: key.label,
frame_channel: video_tx,
audio_channel: audio_tx,
codec: negotiated_codec,
started_at: Utc::now(),
active_clients: 0.into(),
video_pt,
video_profile_level_id: video_profile,
},
);
info!(stream_key_id = key.id, "Whip: StreamSession inserted, spawning detach task");
// Trickle-ICE channel: OBS can send candidates via PATCH after the
// initial offer. We forward them to the Rtc task for add_remote_candidate.
let (trickle_tx, trickle_rx) = tokio::sync::mpsc::unbounded_channel();
let trickle_map = state.appstate.lock().await.webrtc_proxy.trickle_tx.clone();
trickle_map.insert(key.id, trickle_tx);
let db = state.db.clone();
tokio::spawn(async move {
detach_inject_rtc(
stream_sessions,
key.id,
db,
socket,
rx,
trickle_rx,
rtc,
public_addr,
video_rx,
audio_rx,
)
.await;
// Clean up trickle channel when done.
trickle_map.remove(&key.id);
});
stream_session::Model::create_stream_session(&state.db, key.id, Local::now().into())
.await
.ok();
let location = format!("/api/whip/{}", key.id);
axum::response::Response::builder()
.status(StatusCode::CREATED)
.header(header::CONTENT_TYPE, "application/sdp")
.header(header::LOCATION, &location)
// Provide a STUN server via Link header so OBS can gather
// ICE candidates even without explicit STUN configuration.
.header(header::LINK, "<stun:stun.l.google.com:19302>; rel=\"ice-server\"")
.body(axum::body::Body::from(answer_sdp))
.unwrap()
}
async fn detach_inject_rtc(
sessions_ref: Arc<DashMap<i32, StreamSession>>,
stream_key_id: i32,
db: DatabaseConnection,
socket: Arc<UdpSocket>,
mut rx: Receiver<(Bytes, SocketAddr)>,
mut trickle_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
mut rtc: Rtc,
local_addr: SocketAddr,
video_rx: async_broadcast::Receiver<Arc<VideoFrame>>,
audio_rx: async_broadcast::Receiver<Arc<OpusAudioFrame>>,
) {
let cleanup = || {
let sessions_ref = sessions_ref.clone();
let db = db.clone();
async move {
sessions_ref.remove(&stream_key_id);
if let Ok(Some(s)) =
stream_session::Model::get_active_by_stream_key_id(&db, stream_key_id).await
{
s.into_active_model()
.finish_stream_session(&db, Local::now().into())
.await
.ok();
}
}
};
let mut video_mid: Option<Mid> = None;
let mut audio_mid: Option<Mid> = None;
let mut video_tx: Option<async_broadcast::Sender<Arc<VideoFrame>>> = None;
let mut audio_tx: Option<async_broadcast::Sender<Arc<OpusAudioFrame>>> = None;
let mut _connected = false;
loop {
// Blankly using them so they don't drop (like RTMP).
let _ = video_rx.is_closed();
let _ = audio_rx.is_closed();
let deadline = loop {
match rtc.poll_output() {
Ok(Output::Timeout(t)) => break t,
Ok(Output::Transmit(t)) => {
debug!(
"Whip TX: {} bytes → {}:{}",
t.contents.len(),
t.destination.ip(),
t.destination.port()
);
if let Err(e) = socket.send_to(&t.contents, t.destination).await {
warn!("Whip UDP send error: {:?}", e);
cleanup().await;
return;
}
}
Ok(Output::Event(e)) => match e {
Event::MediaAdded(ma) => {
info!(stream_key_id, kind = ?ma.kind, mid = ?ma.mid, "Whip MediaAdded");
if ma.kind == MediaKind::Video {
video_mid = Some(ma.mid);
}
if ma.kind == MediaKind::Audio {
audio_mid = Some(ma.mid);
}
}
Event::MediaData(md) => {
// str0m depacketizes RTP into full codec frames (no decode).
// H.264/H.265 → Annex-B, AV1 → OBU, Opus → raw Opus packets.
// Same format as our RTMP codec parsers produce — push directly.
if Some(md.mid) == video_mid {
let ts_ms = (md.time.as_seconds() * 1000.0) as u32;
let frame = VideoFrame {
data: Bytes::copy_from_slice(&md.data),
is_keyframe: md.is_keyframe(),
timestamp_ms: ts_ms,
};
if let Some(tx) = &video_tx {
tx.broadcast(Arc::new(frame)).await.ok();
}
}
if Some(md.mid) == audio_mid {
let ts_ms = (md.time.as_seconds() * 1000.0) as u32;
let frame = OpusAudioFrame {
data: Bytes::copy_from_slice(&md.data),
timestamp_ms: ts_ms,
};
if let Some(tx) = &audio_tx {
tx.broadcast(Arc::new(frame)).await.ok();
}
}
}
Event::IceConnectionStateChange(state) => {
info!(stream_key_id, ?state, "Whip ICE state change");
if matches!(state, str0m::IceConnectionState::Disconnected) {
info!("Whip ICE disconnected, closing connection");
cleanup().await;
return;
}
}
Event::Connected => {
info!(stream_key_id, "Whip DTLS+ICE connected, wiring broadcast channels");
if let Some(session) = sessions_ref.get(&stream_key_id) {
video_tx = Some(session.frame_channel.clone());
audio_tx = Some(session.audio_channel.clone());
}
_connected = true;
}
_ => {}
},
Err(e) => {
error!("Whip poll_output error (closing): {:?}", e);
cleanup().await;
return;
}
}
};
let sleep = tokio::time::sleep_until(deadline.max(Instant::now()).into());
tokio::select! {
_ = sleep => {
if let Err(e) = rtc.handle_input(Input::Timeout(Instant::now())) {
error!(stream_key_id, "Whip handle_input(Timeout) error: {:?}", e);
cleanup().await;
return;
}
}
// Trickle-ICE candidates from PATCH /api/whip/{id}
Some(candidate_line) = trickle_rx.recv() => {
info!(stream_key_id, %candidate_line, "Whip: received trickle candidate");
// Parse the candidate string (without "a=candidate:" prefix if present).
let cand_str = candidate_line
.strip_prefix("a=candidate:")
.unwrap_or(&candidate_line);
match str0m::Candidate::from_sdp_string(cand_str) {
Ok(c) => {
info!(stream_key_id, "Whip: adding remote candidate");
rtc.add_remote_candidate(c);
}
Err(e) => {
warn!(stream_key_id, %cand_str, "Whip: bad trickle candidate: {:?}", e);
}
}
}
result = rx.recv() => {
let Some((data, from)) = result else {
info!(stream_key_id, "Whip proxy channel closed, cleaning up");
cleanup().await;
return;
};
debug!(
stream_key_id,
len = data.len(),
%from,
"Whip RX: {} bytes",
data.len()
);
if let Ok(contents) = (&data[..]).try_into()
&& let Err(e) = rtc.handle_input(Input::Receive(
Instant::now(),
Receive {
proto: Protocol::Udp,
source: from,
destination: local_addr,
contents,
},
)) {
error!("Whip handle_input(Receive) error: {:?}", e);
cleanup().await;
return;
}
}
}
}
}
/// Extract the negotiated video codec, PT, and profile_level_id from an SDP answer.
///
/// Walks the answer's media lines, finds the first video m-line with
/// negotiated rtp_params, and returns the codec + PT + H.264 profile.
pub fn extract_negotiated_codec_info(
answer: &str0m::change::SdpAnswer,
) -> (Option<crate::StreamCodec>, Option<u8>, Option<u32>) {
use str0m::format::Codec;
for line in answer.media_lines.iter() {
// Iterate rtp_params on every m-line; video check via
// codec.is_video() avoids needing str0m's private MediaType.
for p in line.rtp_params() {
if p.spec().codec.is_video() {
let pt = Some(*p.pt());
let profile = p.spec().format.profile_level_id;
let codec = match p.spec().codec {
Codec::H264 => Some(crate::StreamCodec::H264),
Codec::H265 => Some(crate::StreamCodec::H265),
Codec::Av1 => Some(crate::StreamCodec::AV1),
_ => None,
};
return (codec, pt, profile);
}
}
}
(None, None, None)
}
/// Extract the video codec from an SDP offer's media lines.
///
/// Walks every m= line's `rtp_params()`, maps str0m's `Codec` enum
/// to our `StreamCodec`. Returns the first video codec found.
pub fn video_codec_from_sdp_offer(
sdp_offer: &str0m::change::SdpOffer,
) -> Option<crate::StreamCodec> {
use str0m::format::Codec;
// SdpOffer derefs to Sdp, which has pub media_lines.
// MediaLine is pub(crate) in str0m, but we can call its pub methods.
for line in sdp_offer.media_lines.iter() {
for p in line.rtp_params() {
if p.spec().codec.is_video() {
return match p.spec().codec {
Codec::H264 => Some(crate::StreamCodec::H264),
Codec::H265 => Some(crate::StreamCodec::H265),
Codec::Av1 => Some(crate::StreamCodec::AV1),
_ => None,
};
}
}
}
None
} }
+27 -11
View File
@@ -7,7 +7,6 @@ use std::{
use bytes::Bytes; use bytes::Bytes;
use dashmap::DashMap; use dashmap::DashMap;
use str0m::net::DatagramRecv;
use tokio::{ use tokio::{
net::UdpSocket, net::UdpSocket,
sync::mpsc::{self, Receiver}, sync::mpsc::{self, Receiver},
@@ -24,6 +23,10 @@ pub struct WebrtcProxy {
clients_addr: Arc<DashMap<SocketAddr, tokio::sync::mpsc::Sender<(Bytes, SocketAddr)>>>, clients_addr: Arc<DashMap<SocketAddr, tokio::sync::mpsc::Sender<(Bytes, SocketAddr)>>>,
socket: Arc<UdpSocket>, socket: Arc<UdpSocket>,
public_addr: SocketAddr, public_addr: SocketAddr,
/// Trickle-ICE candidate channels for WHIP ingest.
/// Keyed by stream_key_id; sender stored here so PATCH handler
/// can forward candidates to the detach task.
pub trickle_tx: Arc<DashMap<i32, tokio::sync::mpsc::UnboundedSender<String>>>,
} }
const STUN_MAGIC: u32 = 0x2112A442; const STUN_MAGIC: u32 = 0x2112A442;
@@ -61,6 +64,7 @@ impl WebrtcProxy {
clients_ufrag: Arc::new(DashMap::new()), clients_ufrag: Arc::new(DashMap::new()),
clients_addr: Arc::new(DashMap::new()), clients_addr: Arc::new(DashMap::new()),
public_addr, public_addr,
trickle_tx: Arc::new(DashMap::new()),
}) })
} }
pub async fn run(self) { pub async fn run(self) {
@@ -81,6 +85,7 @@ impl WebrtcProxy {
// By addr // By addr
if let Some(tx) = by_addr.get(&from) { if let Some(tx) = by_addr.get(&from) {
debug!("proxy: routing {} bytes by addr {}:{} → channel", b, from.ip(), from.port());
match tx.try_send((data, from)) { match tx.try_send((data, from)) {
Ok(_) => continue, Ok(_) => continue,
Err(e) => { Err(e) => {
@@ -97,18 +102,24 @@ impl WebrtcProxy {
}; };
}; };
let Some(ufrag) = self::WebrtcProxy::ufrag(&data) else { let Some((part1, part2)) = self::WebrtcProxy::ufrag_pair(&data) else {
debug!("huh, packet isnt stun or added as client."); debug!("huh, packet isnt stun or added as client.");
continue; continue;
}; };
// Try both parts of the STUN username — the first packet
let Some((_, tx)) = by_ufrag.remove(&ufrag) else { // might be a response to OUR STUN request (remote:local)
// warn!("STUN packet ({}), isnt registored", ufrag); // or an incoming request from the remote peer (local:remote).
let part2_lookup = part2.clone();
let entry = by_ufrag.remove(&part1).or_else(|| {
part2_lookup.and_then(|p2| by_ufrag.remove(&p2))
});
let Some((_, tx)) = entry else {
warn!("STUN packet ({}/{:?}), isnt registored", part1, part2);
continue; continue;
}; };
by_addr.insert(from, tx.clone()); by_addr.insert(from, tx.clone());
debug!("got ufrag {}", ufrag); info!("proxy: STUN match → promoted {} → ufrag={} (match was {}/{})", from, part1, part1, part2.as_deref().unwrap_or("-"));
debug!("sending data"); debug!("sending data");
if let Err(e) = tx.try_send((data, from)) { if let Err(e) = tx.try_send((data, from)) {
match e { match e {
@@ -136,7 +147,7 @@ impl WebrtcProxy {
pub fn public_addr(&self) -> SocketAddr { pub fn public_addr(&self) -> SocketAddr {
self.public_addr self.public_addr
} }
pub fn ufrag(b: &Bytes) -> Option<String> { pub fn ufrag_pair(b: &Bytes) -> Option<(String, Option<String>)> {
if b.len() <= 20 { if b.len() <= 20 {
return None; return None;
} }
@@ -149,11 +160,16 @@ impl WebrtcProxy {
while (pos + 4) <= b.len() { while (pos + 4) <= b.len() {
let attr_type: u16 = u16::from_be_bytes(b[pos..pos + 2].try_into().ok()?); let attr_type: u16 = u16::from_be_bytes(b[pos..pos + 2].try_into().ok()?);
let attr_len: u16 = u16::from_be_bytes(b[pos + 2..pos + 4].try_into().ok()?); let attr_len: u16 = u16::from_be_bytes(b[pos + 2..pos + 4].try_into().ok()?);
pos = pos + 4; pos += 4;
if attr_type == 0x0006 { if attr_type == 0x0006 {
let value = std::str::from_utf8(b[pos..pos + (attr_len as usize)].try_into().ok()?); let value = std::str::from_utf8(
let local = value.unwrap().split(":").next(); b[pos..pos + (attr_len as usize)].try_into().ok()?,
return Some(local.unwrap().to_string()); )
.ok()?;
let mut parts = value.split(':');
let first = parts.next()?.to_string();
let second = parts.next().map(|s| s.to_string());
return Some((first, second));
} }
pos += (attr_len as usize + 3) & !3; pos += (attr_len as usize + 3) & !3;
} }
Generated
+6 -6
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"crane": { "crane": {
"locked": { "locked": {
"lastModified": 1780532242, "lastModified": 1785284101,
"narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=", "narHash": "sha256-ghcXEpYEM4a7pbEkoqbn8c0ptJJqgGzuFiG3T6W5g4I=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "59a82a1222dd3b2080b5cc52a1a2e8d5f1b77f37", "rev": "756d6d07c3818ea95d1e2cdac63fa7d02fe3e61b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -35,11 +35,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1781454065, "lastModified": 1785301185,
"narHash": "sha256-d2xfDjnfRuf/xYGdu9VVRHiav/2w5hDL/5cw2TuVAXw=", "narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "9eac87a12312b8f60dd52e1c6e1a265f6fc7f5fc", "rev": "9bc02893134c733dd85de46ee4fb2fac696b5529",
"type": "github" "type": "github"
}, },
"original": { "original": {