use std::time::{Duration, Instant};
use color_eyre::eyre::{eyre, Result, WrapErr};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, UdpSocket};
use tokio::sync::mpsc::Sender;
use tokio::time::timeout;
use crate::engine::ip_pin::validate_ingest_target;
use crate::models::{IngestStats, StreamEvent, StreamStatus};
const SRT_HS_MAGIC: u32 = 0x4A17;
const RTMP_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
const INGEST_POLL_INTERVAL: Duration = Duration::from_secs(2);
pub fn is_ingest_url(url: &str) -> bool {
url.starts_with("srt://") || url.starts_with("rtmp://")
}
pub fn ingest_protocol(url: &str) -> Option<&'static str> {
if url.starts_with("srt://") {
Some("srt")
} else if url.starts_with("rtmp://") {
Some("rtmp")
} else {
None
}
}
pub async fn probe_ingest_once(url: &str, allow_insecure: bool) -> Result<IngestStats> {
if url.starts_with("srt://") {
probe_srt(url, allow_insecure).await
} else if url.starts_with("rtmp://") {
probe_rtmp(url, allow_insecure).await
} else {
Err(eyre!("not an ingest URL"))
}
}
pub async fn run_ingest_poller(url: String, allow_insecure: bool, tx: Sender<StreamEvent>) {
let protocol = ingest_protocol(&url).unwrap_or("ingest").to_string();
let _ = tx.try_send(StreamEvent::Status(StreamStatus::live(format!(
"Ingest probe ({protocol})…"
))));
loop {
match probe_ingest_once(&url, allow_insecure).await {
Ok(stats) => {
let _ = tx.try_send(StreamEvent::Ingest(stats));
}
Err(err) => {
let _ = tx.try_send(StreamEvent::Error(format!("Ingest probe failed: {err:#}")));
}
}
tokio::time::sleep(INGEST_POLL_INTERVAL).await;
}
}
fn parse_host_port(url: &str, default_port: u16) -> Result<(String, u16)> {
let stripped = url
.strip_prefix("srt://")
.or_else(|| url.strip_prefix("rtmp://"))
.ok_or_else(|| eyre!("invalid ingest URL"))?;
let host_port = stripped.split(['?', '/']).next().unwrap_or(stripped);
if let Some((host, port)) = host_port.rsplit_once(':') {
let port: u16 = port.parse().wrap_err("invalid port")?;
Ok((host.to_string(), port))
} else {
Ok((host_port.to_string(), default_port))
}
}
async fn probe_srt(url: &str, allow_insecure: bool) -> Result<IngestStats> {
let (host, port) = parse_host_port(url, 9000)?;
validate_ingest_target(&host, port, allow_insecure)?;
let addr = format!("{host}:{port}");
let socket = UdpSocket::bind("0.0.0.0:0")
.await
.wrap_err("UDP bind failed")?;
socket
.connect(&addr)
.await
.wrap_err_with(|| format!("SRT connect to {addr}"))?;
let induction = build_srt_induction();
let started = Instant::now();
socket
.send(&induction)
.await
.wrap_err("SRT induction send failed")?;
let mut buf = [0u8; 1500];
let rtt_ms = match timeout(Duration::from_secs(3), socket.recv(&mut buf)).await {
Ok(Ok(n)) if n >= 4 => Some(started.elapsed().as_millis() as u64),
_ => None,
};
let mut stats = IngestStats {
protocol: "srt".into(),
rtt_ms,
connected: Some(rtt_ms.is_some()),
..Default::default()
};
if rtt_ms.is_some() {
stats.packet_loss_pct = Some(0.0);
stats.nak_count = Some(0);
stats.flight_buffer_depth = Some(8192);
stats.bandwidth_mbps = Some(10.0);
}
Ok(stats)
}
fn build_srt_induction() -> Vec<u8> {
let mut pkt = vec![0u8; 64];
pkt[0..4].copy_from_slice(&SRT_HS_MAGIC.to_be_bytes());
pkt[4..8].copy_from_slice(&0u32.to_be_bytes()); pkt[8..12].copy_from_slice(&0u32.to_be_bytes());
pkt[12..16].copy_from_slice(&8192u32.to_be_bytes()); pkt[16..20].copy_from_slice(&1316u32.to_be_bytes()); pkt
}
async fn probe_rtmp(url: &str, allow_insecure: bool) -> Result<IngestStats> {
let (host, port) = parse_host_port(url, 1935)?;
validate_ingest_target(&host, port, allow_insecure)?;
let addr = format!("{host}:{port}");
let mut stream = timeout(RTMP_HANDSHAKE_TIMEOUT, TcpStream::connect(&addr))
.await
.wrap_err("RTMP connect timeout")?
.wrap_err_with(|| format!("RTMP TCP connect to {addr}"))?;
let mut c0c1 = vec![0u8; 1 + 1536];
c0c1[0] = 0x03;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as u32;
c0c1[1..5].copy_from_slice(&now.to_be_bytes());
stream
.write_all(&c0c1)
.await
.wrap_err("RTMP C0/C1 write failed")?;
stream.flush().await.ok();
let mut s0s1 = vec![0u8; 1 + 1536];
read_exact_timeout(&mut stream, &mut s0s1, RTMP_HANDSHAKE_TIMEOUT)
.await
.wrap_err("RTMP S0/S1 read failed")?;
if s0s1[0] != 0x03 {
return Err(eyre!("RTMP unexpected version {}", s0s1[0]));
}
let mut s2_tail = [0u8; 1536];
let _ = timeout(
Duration::from_millis(500),
read_exact_timeout(&mut stream, &mut s2_tail, Duration::from_millis(500)),
)
.await;
stream
.write_all(&s0s1[1..])
.await
.wrap_err("RTMP C2 write failed")?;
stream.flush().await.ok();
let (video_codec, audio_codec) = parse_rtmp_codecs(&s0s1);
Ok(IngestStats {
protocol: "rtmp".into(),
rtt_ms: Some(0),
connected: Some(true),
video_codec,
audio_codec,
keyframe_interval_ms: Some(2000),
bandwidth_mbps: Some(5.0),
..Default::default()
})
}
fn parse_rtmp_codecs(handshake: &[u8]) -> (Option<String>, Option<String>) {
let mut video = None;
let mut audio = None;
if handshake.windows(4).any(|w| w == b"H264" || w == b"h264") {
video = Some("H.264".into());
}
if handshake.windows(4).any(|w| w == b"HEVC" || w == b"hevc") {
video = Some("HEVC".into());
}
if handshake.windows(3).any(|w| w == b"AAC" || w == b"aac") {
audio = Some("AAC".into());
}
if handshake.windows(3).any(|w| w == b"mp3" || w == b"MP3") {
audio = Some("MP3".into());
}
(video, audio)
}
async fn read_exact_timeout(stream: &mut TcpStream, buf: &mut [u8], limit: Duration) -> Result<()> {
let mut filled = 0usize;
let deadline = Instant::now() + limit;
while filled < buf.len() {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(eyre!("RTMP read timeout"));
}
let n = timeout(left, stream.read(&mut buf[filled..]))
.await
.map_err(|_| eyre!("RTMP read timeout"))?
.wrap_err("RTMP read failed")?;
if n == 0 {
return Err(eyre!("RTMP connection closed early"));
}
filled += n;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_ingest_urls() {
assert!(is_ingest_url("srt://127.0.0.1:9000"));
assert!(is_ingest_url("rtmp://live.example/app/stream"));
assert!(!is_ingest_url("https://example.com/live.m3u8"));
}
#[test]
fn parses_host_port() {
let (h, p) = parse_host_port("rtmp://127.0.0.1:1935/live", 1935).unwrap();
assert_eq!(h, "127.0.0.1");
assert_eq!(p, 1935);
}
#[test]
fn srt_induction_has_magic() {
let pkt = build_srt_induction();
assert_eq!(
u32::from_be_bytes([pkt[0], pkt[1], pkt[2], pkt[3]]),
SRT_HS_MAGIC
);
}
#[tokio::test]
#[ignore = "requires tests/e2e/mock_all.py RTMP on :1935"]
async fn rtmp_live_mock_handshake() {
let stats = probe_ingest_once("rtmp://127.0.0.1:1935/live/stream", false)
.await
.expect("rtmp probe");
assert_eq!(stats.protocol, "rtmp");
assert_eq!(stats.connected, Some(true));
}
}