use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use multimux::config::{Config, InputSpec, Route};
use multimux::dvr::DvrConfig;
use multimux::output::OutputKind;
use multimux::registry::SchemeRegistry;
use multimux::serve_with_registry;
fn fixture_path() -> String {
format!("{}/../fixtures/ts/h264_aac.ts", env!("CARGO_MANIFEST_DIR"))
}
fn reserve_tcp_addr() -> SocketAddr {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve tcp port");
let addr = listener.local_addr().expect("local addr");
drop(listener);
addr
}
fn file_config(bind: SocketAddr, path: String, loop_file: bool) -> Config {
Config {
bind: bind.to_string(),
target_duration_secs: 0.5,
part_target_ms: 100,
window_segments: 8,
routes: vec![Route {
name: "cam".to_string(),
input: InputSpec::File { path, loop_file },
outputs: vec![OutputKind::LlHls],
dvr: DvrConfig::default(),
}],
..Config::default()
}
}
async fn poll_until_extinf(client: &reqwest::Client, playlist_url: &str) -> String {
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
loop {
if let Ok(resp) = client.get(playlist_url).send().await
&& resp.status().is_success()
&& let Ok(body) = resp.text().await
&& body.contains("#EXTINF:")
{
return body;
}
if tokio::time::Instant::now() >= deadline {
panic!(
"no #EXTINF: line appeared in {playlist_url} within the hang guard -- \
file route never produced a servable closed segment"
);
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[tokio::test]
async fn file_route_serves_real_media_segments() {
let bind_addr = reserve_tcp_addr();
let config = file_config(bind_addr, fixture_path(), false);
let server = tokio::spawn(serve_with_registry(config, SchemeRegistry::new()));
let client = reqwest::Client::new();
let playlist_url = format!("http://{bind_addr}/cam/media.m3u8");
let playlist = poll_until_extinf(&client, &playlist_url).await;
assert!(
playlist.contains("#EXTINF:"),
"media playlist must carry a real closed-segment #EXTINF line: {playlist}"
);
let init_url = format!("http://{bind_addr}/cam/init-1.mp4");
let _init: bytes::Bytes = get_non_empty(&client, &init_url).await;
server.abort();
}
async fn get_non_empty(client: &reqwest::Client, url: &str) -> bytes::Bytes {
let resp = client
.get(url)
.send()
.await
.unwrap_or_else(|e| panic!("GET {url} failed: {e}"));
assert_eq!(resp.status(), reqwest::StatusCode::OK, "GET {url}");
let body = resp
.bytes()
.await
.unwrap_or_else(|e| panic!("reading body of {url} failed: {e}"));
assert!(!body.is_empty(), "GET {url}: body must be non-empty");
body
}
#[test]
fn file_is_the_feature() {
let _ = InputSpec::File {
path: "/a.ts".to_string(),
loop_file: true,
};
let _arc: Arc<()> = Arc::new(());
let _ = _arc;
}
fn segment_uris(playlist: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = playlist;
while let Some(i) = rest.find("seg-") {
let seg = &rest[i..];
let end = seg.find(".m4s").map(|e| e + ".m4s".len()).unwrap_or(0);
if end > 0 {
out.push(seg[..end].to_string());
rest = &seg[end..];
} else {
rest = &seg[1..];
}
}
out
}
#[tokio::test]
async fn file_route_loop_true_keeps_serving() {
let bind = reserve_tcp_addr();
let config = file_config(bind, fixture_path(), true);
let server = tokio::spawn(serve_with_registry(config, SchemeRegistry::new()));
let client = reqwest::Client::new();
let playlist_url = format!("http://{bind}/cam/media.m3u8");
poll_until_extinf(&client, &playlist_url).await;
let t0 = segment_uris(
&client
.get(&playlist_url)
.send()
.await
.unwrap()
.text()
.await
.unwrap(),
);
assert!(!t0.is_empty(), "the first pass must serve segments");
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
loop {
let body = client
.get(&playlist_url)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
let now = segment_uris(&body);
let fresh = now.iter().filter(|u| !t0.contains(u)).count();
if fresh > 0 {
server.abort();
return;
}
assert!(
tokio::time::Instant::now() < deadline,
"loop:true must keep serving fresh segments past the first pass; only saw {:?}",
t0
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn total_extinf_secs(playlist: &str) -> f64 {
let mut total = 0.0;
for line in playlist.lines() {
if let Some(rest) = line.strip_prefix("#EXTINF:")
&& let Some(comma) = rest.find(',')
&& let Ok(secs) = rest[..comma].trim().parse::<f64>()
{
total += secs;
}
}
total
}
#[tokio::test]
async fn file_route_loop_false_stops() {
let bind = reserve_tcp_addr();
let config = file_config(bind, fixture_path(), false);
let server = tokio::spawn(serve_with_registry(config, SchemeRegistry::new()));
let client = reqwest::Client::new();
let playlist_url = format!("http://{bind}/cam/media.m3u8");
poll_until_extinf(&client, &playlist_url).await;
tokio::time::sleep(Duration::from_secs(6)).await;
let body = client
.get(&playlist_url)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
let t0 = segment_uris(&body);
assert!(
!t0.is_empty(),
"loop:false must serve the first pass's segments"
);
let served = total_extinf_secs(&body);
assert!(
served >= 2.955 - 0.1,
"loop:false must serve the file's tail: served {served:.3}s, expected ~2.955s \
(a parked-but-healthy driver that skips the flush drops up to one target_duration)"
);
tokio::time::sleep(Duration::from_secs(6)).await;
let later = segment_uris(
&client
.get(&playlist_url)
.send()
.await
.unwrap()
.text()
.await
.unwrap(),
);
let fresh = later.iter().filter(|u| !t0.contains(u)).count();
assert_eq!(
fresh,
0,
"loop:false must stop producing segments, but {:?} is new",
later.iter().filter(|u| !t0.contains(u)).collect::<Vec<_>>()
);
server.abort();
}
#[tokio::test]
async fn file_route_loop_true_paces_near_realtime() {
let bind = reserve_tcp_addr();
let config = file_config(bind, fixture_path(), true);
let server = tokio::spawn(serve_with_registry(config, SchemeRegistry::new()));
let client = reqwest::Client::new();
let playlist_url = format!("http://{bind}/cam/media.m3u8");
poll_until_extinf(&client, &playlist_url).await;
let start = tokio::time::Instant::now();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let want = 3usize;
let deadline = start + Duration::from_secs(20);
loop {
let body = client
.get(&playlist_url)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
for u in segment_uris(&body) {
seen.insert(u);
}
if seen.len() >= want {
break;
}
assert!(
tokio::time::Instant::now() < deadline,
"loop:true must keep producing segments; only saw {seen:?}"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_millis(600),
"segments must appear at roughly realtime, not 300× — {want} closed segments in {elapsed:?}"
);
assert!(
elapsed <= Duration::from_secs(10),
"segment cadence must not stall — {want} closed segments took {elapsed:?}"
);
server.abort();
}
#[tokio::test]
async fn file_route_loop_true_paces_past_first_pass() {
let bind = reserve_tcp_addr();
let config = file_config(bind, fixture_path(), true);
let server = tokio::spawn(serve_with_registry(config, SchemeRegistry::new()));
let client = reqwest::Client::new();
let playlist_url = format!("http://{bind}/cam/media.m3u8");
poll_until_extinf(&client, &playlist_url).await;
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut at_6: Option<tokio::time::Instant> = None;
let mut at_8: Option<tokio::time::Instant> = None;
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
while seen.len() < 8 {
let body = client
.get(&playlist_url)
.send()
.await
.unwrap()
.text()
.await
.unwrap();
for u in segment_uris(&body) {
seen.insert(u);
}
if seen.len() >= 6 && at_6.is_none() {
at_6 = Some(tokio::time::Instant::now());
}
if seen.len() >= 8 && at_8.is_none() {
at_8 = Some(tokio::time::Instant::now());
}
assert!(
tokio::time::Instant::now() < deadline,
"must reach 8 distinct segments within the hang guard; only saw {seen:?}"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
let at_6 = at_6.expect("6th segment observed");
let at_8 = at_8.expect("8th segment observed");
let span = at_8 - at_6;
assert!(
span >= Duration::from_millis(400),
"segments past the loop point must stay at roughly realtime, not dump all of pass 2 at once: segments 6→8 in {span:?}"
);
server.abort();
}