Bump: 0.5.0 : unlisted, password and locked stream support

This commit is contained in:
2026-08-16 14:56:29 +01:00
parent 9960434c1c
commit 0c6705badd
13 changed files with 319 additions and 45 deletions
+100 -21
View File
@@ -22,7 +22,7 @@ use axum::{
Json, Router,
extract::{FromRequestParts, Path, State},
http::{
HeaderName, Method, StatusCode,
HeaderMap, HeaderName, Method, StatusCode,
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
request::Parts,
},
@@ -31,7 +31,7 @@ use axum::{
};
use chrono::{DateTime, Utc};
use entity::{auth_session, stream_key, stream_session, users};
use sea_orm::{DatabaseConnection, EntityTrait, IntoActiveModel};
use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, IntoActiveModel, Set};
use serde::{Deserialize, Serialize};
use sysinfo::System;
use tokio::{
@@ -149,7 +149,14 @@ struct StreamListing {
#[derive(Deserialize)]
struct EditStreamKeyRequest {
id: i32,
new: String,
#[serde(default)]
new: Option<String>,
#[serde(default)]
password: Option<String>,
#[serde(default)]
unlisted: Option<bool>,
#[serde(default)]
custom_id: Option<String>,
}
async fn edit_stream_key(
@@ -158,14 +165,16 @@ async fn edit_stream_key(
Json(payload): Json<EditStreamKeyRequest>,
) -> Result<impl IntoResponse, HttpError> {
// Trim surrounding whitespace so labels aren't stored with leading/trailing spaces.
let new_label = payload.new.trim();
let new_label = payload.new.as_deref().map(str::trim);
// Length (1..=67) and allowed charset ([A-Za-z0-9 _-], space included).
if !KEY_RE.is_match(new_label) {
return Err(HttpError::BadRequest("invalid label".into()));
}
// Replaces the JS lookahead: label must contain at least one letter.
if !new_label.chars().any(|c| c.is_ascii_alphabetic()) {
return Err(HttpError::BadRequest("label must contain a letter".into()));
if let Some(label) = new_label {
if !KEY_RE.is_match(label) {
return Err(HttpError::BadRequest("invalid label".into()));
}
// Replaces the JS lookahead: label must contain at least one letter.
if !label.chars().any(|c| c.is_ascii_alphabetic()) {
return Err(HttpError::BadRequest("label must contain a letter".into()));
}
}
let stream_key = stream_key::Entity::find_by_id(payload.id)
@@ -176,10 +185,60 @@ async fn edit_stream_key(
if stream_key.user_id != auth.0.id {
return Err(HttpError::Forbidden);
}
stream_key
.into_active_model()
.change_label_value(&state.db, new_label.to_string())
.await?;
let lock = state.appstate.lock().await;
let mut ses = { lock.stream_sessions.get_mut(&payload.id) };
// drop(lock);
let mut am = stream_key.into_active_model();
if let Some(custom_id) = payload.custom_id {
am.custom_id = if custom_id.is_empty() {
if let Some(ref mut ses) = ses {
ses.custom_id = None;
}
Set(None)
} else {
// Check for conflict.
if stream_key::Entity::find_by_custom_id(&state.db, custom_id.clone())
.await?
.is_some()
{
return Err(HttpError::Conflict);
}
if let Some(ref mut ses) = ses {
ses.custom_id = Some(custom_id.clone());
}
Set(Some(custom_id))
};
}
if let Some(label) = new_label {
if let Some(ref mut ses) = ses {
ses.stream_key_label = label.to_string();
}
am.label = Set(label.to_string());
}
if let Some(pwd) = payload.password {
am.password = if pwd.is_empty() {
if let Some(ref mut ses) = ses {
ses.password = None;
}
Set(None)
} else {
if let Some(ref mut ses) = ses {
ses.password = Some(pwd.clone());
}
Set(Some(pwd))
};
}
if let Some(unlisted) = payload.unlisted {
if let Some(ref mut ses) = ses {
ses.is_unlisted = unlisted;
}
am.is_unlisted = Set(unlisted);
}
// This hopefully will not fail, if it does, our values for the stream key will be mismatched,
// that would be bad
am.update(&state.db).await?;
Ok(StatusCode::OK)
}
@@ -195,6 +254,7 @@ async fn catalog_handler(
.await
.stream_sessions
.iter()
.filter(|x| !x.is_unlisted)
.map(|x| StreamListing {
id: x.stream_key_id,
label: x.stream_key_label.clone(),
@@ -412,16 +472,18 @@ async fn create_user_handler(
async fn stream_handler(
State(state): State<Arc<HttpServer>>,
Path(slug): Path<String>,
headers: HeaderMap,
body: String,
) -> Result<impl IntoResponse, HttpError> {
let request_id_clone = state.request_count.fetch_add(1, Ordering::Relaxed);
let app = state.appstate.lock().await;
let stream_key_id = {
let app = state.appstate.lock().await;
app.stream_sessions
.iter()
.find(|e| e.value().stream_key_id.to_string() == slug)
.map(|e| *e.key())
app.stream_sessions.iter().find(|e| {
e.value().stream_key_id.to_string() == slug
|| e.value().custom_id.as_deref() == Some(slug.as_str())
})
// .map(|e| *e.key())
};
let stream_key_id = stream_key_id.ok_or_else(|| {
@@ -429,12 +491,29 @@ async fn stream_handler(
HttpError::NotFound
})?;
info!(request_id = request_id_clone, slug = %slug, stream_key_id, "WHEP offer received");
// Dont return stream by its id in the db if its unlisted
if stream_key_id.is_unlisted && stream_key_id.custom_id.clone().ok_or("") != Ok(slug.clone()) {
return Err(HttpError::NotFound);
}
let auth_header = headers.get("auth");
if let Some(password) = &stream_key_id.password {
if let Some(auth) = auth_header {
if auth.to_str().unwrap() != password {
return Err(HttpError::Unauthorized);
}
} else {
return Err(HttpError::Unauthorized);
};
};
info!(request_id = request_id_clone, slug = %slug, stream_key_id.stream_key_id, "WHEP offer received");
let accept_rx = state.accept_rx.activate_cloned();
// The webrtc worker owning the receiver died if this fails.
state
.offer_tx
.send((request_id_clone, stream_key_id, body))
.send((request_id_clone, stream_key_id.stream_key_id, body))
.await
.map_err(|_| HttpError::Internal)?;
debug!(
+4
View File
@@ -50,6 +50,9 @@ pub struct StreamSession {
pub stream_key_id: i32,
pub stream_key_label: String,
pub stream_key_user: String,
pub custom_id: Option<String>,
pub is_unlisted: bool,
pub password: Option<String>,
pub frame_channel: async_broadcast::Sender<Arc<VideoFrame>>,
pub audio_channel: async_broadcast::Sender<Arc<OpusAudioFrame>>,
pub codec: Option<StreamCodec>,
@@ -98,6 +101,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
webrtc_proxy: proxy.clone(),
}));
let (offer_tx, offer_rx) = tokio::sync::mpsc::channel::<(i32, i32, String)>(64);
// Request_Id,
// String_Label,
// Offer_body
+3
View File
@@ -312,6 +312,9 @@ impl Rtmp {
stream_key_id: key.id,
stream_key_label: key.label,
stream_key_user: user.username,
custom_id: key.custom_id,
is_unlisted: key.is_unlisted,
password: key.password,
frame_channel: video_tx.clone(),
audio_channel: audio_tx.clone(),
codec: None,
+21 -12
View File
@@ -4,7 +4,7 @@ use entity::stream_key;
use sea_orm::{DatabaseConnection, EntityTrait};
use std::{net::SocketAddr, sync::Arc, time::Instant};
use tokio::{net::UdpSocket, sync::mpsc::Receiver};
use tracing::{debug, error, info, warn};
use tracing::{debug, error, info, trace, warn};
use str0m::{
Candidate, Event, Input, Output, Rtc,
@@ -40,7 +40,10 @@ impl Webrtc {
request_id,
stream_id, "stream key not found in DB, rejecting offer"
);
self.accept_tx.broadcast((request_id, Err(String::new()))).await.unwrap();
self.accept_tx
.broadcast((request_id, Err(String::new())))
.await
.unwrap();
continue;
}
if let Err(ref e) = stream_key {
@@ -48,7 +51,10 @@ impl Webrtc {
request_id,
stream_id, "DB error looking up stream key: {:?}", e
);
self.accept_tx.broadcast((request_id, Err(String::new()))).await.unwrap();
self.accept_tx
.broadcast((request_id, Err(String::new())))
.await
.unwrap();
continue;
}
@@ -134,7 +140,10 @@ impl Webrtc {
Ok(sdp) => sdp,
Err(e) => {
warn!(request_id, stream_id, "malformed SDP offer: {:?}", e);
self.accept_tx.broadcast((request_id, Err(String::new()))).await.unwrap();
self.accept_tx
.broadcast((request_id, Err(String::new())))
.await
.unwrap();
continue;
}
};
@@ -162,7 +171,7 @@ impl Webrtc {
}
};
let answer_sdp = offer_answer.to_sdp_string();
info!(request_id, "SDP answer:\n{}", answer_sdp);
trace!(request_id, "SDP answer:\n{}", answer_sdp);
// Detect the case where str0m couldn't match any video codec.
// str0m serialises the m-line with an empty PT list, which is invalid SDP
@@ -179,14 +188,14 @@ impl Webrtc {
"no video codec negotiated — browser likely doesn't support {:?}; rejecting offer",
stream_codec
);
self.accept_tx
.broadcast((request_id, Err(format!("{:?}", stream_codec))))
.await
.unwrap();
continue;
}
self.accept_tx
.broadcast((request_id, Err(format!("{:?}", stream_codec))))
.await
.unwrap();
continue;
}
let ufrag = answer_sdp
let ufrag = answer_sdp
.lines()
.find(|l| l.starts_with("a=ice-ufrag:"))
.and_then(|l| l.strip_prefix("a=ice-ufrag:"))
+40 -5
View File
@@ -1,4 +1,8 @@
use std::{net::SocketAddr, sync::Arc, time::{Duration, Instant}};
use std::{
net::SocketAddr,
sync::Arc,
time::{Duration, Instant},
};
use async_broadcast::broadcast;
use axum::{
@@ -298,6 +302,9 @@ pub async fn handle_whip_injest(
stream_key_id: key.id,
stream_key_label: key.label,
stream_key_user: user.username,
custom_id: key.custom_id,
is_unlisted: key.is_unlisted,
password: key.password,
frame_channel: video_tx,
audio_channel: audio_tx,
codec: negotiated_codec,
@@ -372,6 +379,7 @@ async fn detach_inject_rtc(
let sessions_ref = sessions_ref.clone();
let db = db.clone();
async move {
debug!("Cleaning up {:?}", &stream_key_id);
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
@@ -388,6 +396,7 @@ async fn detach_inject_rtc(
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;
let mut disconnect_timer: Option<Instant> = None;
loop {
// Blankly using them so they don't drop (like RTMP).
@@ -398,6 +407,7 @@ async fn detach_inject_rtc(
match rtc.poll_output() {
Ok(Output::Timeout(t)) => break t,
Ok(Output::Transmit(t)) => {
// The keep alive loop, by default is every 1 second.
trace!(
"Whip TX: {} bytes → {}:{}",
t.contents.len(),
@@ -409,6 +419,22 @@ async fn detach_inject_rtc(
cleanup().await;
return;
}
// 30 Sec time out if disconnected, then we clean up and disconnect.
if let Some(instant_since_disconnet) = disconnect_timer
&& Instant::now()
.duration_since(instant_since_disconnet)
.as_secs()
> NO_MEDIA_TIMEOUT.as_secs()
{
info!(
"WHIP connection on stream_key_id: {:?}, has been disconnected for {:?} seconds. Cleaning up and destroying the connection,",
stream_key_id,
NO_MEDIA_TIMEOUT.as_secs()
);
cleanup().await;
rtc.disconnect();
return;
}
}
Ok(Output::Event(e)) => match e {
Event::MediaAdded(ma) => {
@@ -448,10 +474,19 @@ async fn detach_inject_rtc(
}
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;
match state {
str0m::IceConnectionState::Disconnected => {
info!(
"Whip ICE disconnected... (State changed to Disconnected for {:?}) ((This is usually due to network jitter))",
&stream_key_id
);
disconnect_timer = Some(Instant::now());
}
str0m::IceConnectionState::Connected
| str0m::IceConnectionState::Completed => {
disconnect_timer = None;
}
_ => {}
}
}
Event::Connected => {