use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
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() -> PathBuf {
PathBuf::from(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../fixtures/ts/h264_aac.ts"
))
}
fn rtmp_fixture_path() -> PathBuf {
PathBuf::from(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/rtmp-obs-publish.bin"
))
}
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 reserve_udp_addr() -> SocketAddr {
let socket = std::net::UdpSocket::bind("127.0.0.1:0").expect("reserve udp port");
let addr = socket.local_addr().expect("local addr");
drop(socket);
addr
}
fn base_config(bind: SocketAddr, input: InputSpec) -> 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,
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 {
if resp.status().is_success() {
if let Ok(body) = resp.text().await {
if body.contains("#EXTINF:") {
return body;
}
}
}
}
if tokio::time::Instant::now() >= deadline {
panic!(
"no #EXTINF: line appeared in {playlist_url} within the hang guard -- \
dispatched ingest never produced a closed segment"
);
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn first_segment_uri(playlist: &str) -> &str {
let start = playlist
.find("seg-")
.unwrap_or_else(|| panic!("no seg-*.m4s URI in playlist: {playlist}"));
let rest = &playlist[start..];
let end = rest
.find(".m4s")
.unwrap_or_else(|| panic!("no .m4s in playlist: {playlist}"))
+ ".m4s".len();
&rest[..end]
}
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
}
#[tokio::test]
async fn ts_udp_dispatch_serves_real_media_end_to_end() {
let bind_addr = reserve_tcp_addr();
let udp_addr = reserve_udp_addr();
let config = base_config(
bind_addr,
InputSpec::TsUdp {
addr: udp_addr.to_string(),
multicast_group: None,
},
);
let server = tokio::spawn(serve_with_registry(config, SchemeRegistry::new()));
let ts_bytes = std::fs::read(fixture_path()).expect("h264_aac.ts fixture must exist");
let stop = Arc::new(AtomicBool::new(false));
let sender_stop = Arc::clone(&stop);
let sender = tokio::net::UdpSocket::bind("127.0.0.1:0")
.await
.expect("bind sender");
let send_task = tokio::spawn(async move {
while !sender_stop.load(Ordering::Relaxed) {
for chunk in ts_bytes.chunks(7 * 188) {
let _ = sender.send_to(chunk, udp_addr).await;
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
});
let client = reqwest::Client::new();
let playlist_url = format!("http://{bind_addr}/cam/media.m3u8");
let playlist = poll_until_extinf(&client, &playlist_url).await;
stop.store(true, Ordering::Relaxed);
assert!(
playlist.contains("#EXTINF:"),
"media playlist must carry a real closed-segment #EXTINF line: {playlist}"
);
let init_bytes = get_non_empty(&client, &format!("http://{bind_addr}/cam/init-1.mp4")).await;
let _ = init_bytes;
let seg_uri = first_segment_uri(&playlist).to_string();
let _seg_bytes = get_non_empty(&client, &format!("http://{bind_addr}/cam/{seg_uri}")).await;
send_task.abort();
server.abort();
}
#[tokio::test]
async fn ts_http_dispatch_serves_real_media_end_to_end() {
use axum::Router;
use axum::body::Body;
use axum::response::IntoResponse;
use axum::routing::get;
let ts_bytes = std::fs::read(fixture_path()).expect("h264_aac.ts fixture must exist");
async fn handler(body: axum::extract::State<Vec<u8>>) -> axum::response::Response {
let chunks: Vec<std::result::Result<Vec<u8>, std::io::Error>> =
body.0.chunks(7 * 188).map(|c| Ok(c.to_vec())).collect();
let stream = futures_util::stream::iter(chunks);
let body = Body::from_stream(stream);
([(axum::http::header::CONTENT_TYPE, "video/mp2t")], body).into_response()
}
let app = Router::new()
.route("/stream.ts", get(handler))
.with_state(ts_bytes);
let ts_listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port for the ts source");
let ts_addr = ts_listener.local_addr().expect("local addr");
let ts_server = tokio::spawn(async move {
axum::serve(ts_listener, app).await.expect("axum ts server");
});
let bind_addr = reserve_tcp_addr();
let config = base_config(
bind_addr,
InputSpec::TsHttp {
url: format!("http://{ts_addr}/stream.ts"),
auth: None,
},
);
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_bytes = get_non_empty(&client, &format!("http://{bind_addr}/cam/init-1.mp4")).await;
let seg_uri = first_segment_uri(&playlist).to_string();
let _seg_bytes = get_non_empty(&client, &format!("http://{bind_addr}/cam/{seg_uri}")).await;
server.abort();
ts_server.abort();
}
#[tokio::test]
async fn rtmp_dispatch_serves_real_media_end_to_end() {
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpStream;
let bind_addr = reserve_tcp_addr();
let rtmp_addr = reserve_tcp_addr();
let config = base_config(
bind_addr,
InputSpec::Rtmp {
listen: rtmp_addr.to_string(),
app: None,
stream_key: None,
},
);
let server = tokio::spawn(serve_with_registry(config, SchemeRegistry::new()));
let fixture = std::fs::read(rtmp_fixture_path()).expect("rtmp fixture must exist");
let publisher = tokio::spawn(async move {
let mut stream = None;
for _ in 0..200 {
match TcpStream::connect(rtmp_addr).await {
Ok(s) => {
stream = Some(s);
break;
}
Err(_) => tokio::time::sleep(Duration::from_millis(10)).await,
}
}
let mut stream = stream.expect("connect to the RTMP listener");
stream
.write_all(&fixture)
.await
.expect("write rtmp publish bytes");
let mut sink = [0u8; 8192];
loop {
match stream.read(&mut sink).await {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
}
});
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_bytes = get_non_empty(&client, &format!("http://{bind_addr}/cam/init-1.mp4")).await;
let seg_uri = first_segment_uri(&playlist).to_string();
let _seg_bytes = get_non_empty(&client, &format!("http://{bind_addr}/cam/{seg_uri}")).await;
publisher.abort();
server.abort();
}
mod custom_dispatch_driver_backed {
use super::*;
use std::collections::VecDeque;
use std::convert::Infallible;
use std::num::NonZeroUsize;
use broadcast_common::{Demand, Stage, Timestamp};
use media_plane::ingress::{
Dialer, HandshakePolicy, IngestDriver, IngestSession, ProgramId, SessionEvent,
};
use media_plane::trunk::{RetentionClass, TrunkConfig};
use multimux::registry::{InputCtx, InputFactory};
use multimux::route::RouteHandle;
use multimux::source::{DriverProgress, advance_route};
use multimux::{Backoff, supervise_driver};
use transmux::TsDemux;
use transmux::pipeline::{CodecConfig, Sample, TrackSpec};
fn nz(n: usize) -> NonZeroUsize {
NonZeroUsize::new(n).expect("non-zero capacity")
}
fn real_video_track_and_samples() -> (TrackSpec, Vec<Sample>) {
let ts = std::fs::read(fixture_path()).expect("h264_aac.ts fixture must exist");
let media = TsDemux::new().demux(&ts).expect("demux h264_aac.ts");
let video = media
.tracks
.into_iter()
.find(|t| matches!(t.spec.config, CodecConfig::Avc { .. }))
.expect("h264_aac.ts must carry an AVC video track");
(video.spec, video.samples)
}
struct RealTsSession {
pending: VecDeque<SessionEvent>,
sent: bool,
spec: TrackSpec,
samples: Vec<Sample>,
}
impl Stage for RealTsSession {
type In<'a> = &'a [u8];
type Out = SessionEvent;
type Error = Infallible;
fn demand(&self) -> Demand {
Demand::new(1)
}
fn feed(&mut self, _input: &[u8], _now: Timestamp) -> Result<(), Infallible> {
if !self.sent {
self.sent = true;
self.pending.push_back(SessionEvent::NewProgram {
program: ProgramId(0),
tracks: vec![self.spec.clone()],
});
let track_id = self.spec.track_id;
for sample in self.samples.drain(..) {
self.pending.push_back(SessionEvent::Sample {
program: ProgramId(0),
track_id,
retention: RetentionClass::Timed,
sample,
});
}
}
Ok(())
}
fn poll(&mut self) -> Option<SessionEvent> {
self.pending.pop_front()
}
fn next_deadline(&self) -> Option<Timestamp> {
None
}
fn on_deadline(&mut self, _now: Timestamp) {}
fn finish(&mut self) -> Result<(), Infallible> {
Ok(())
}
}
impl IngestSession for RealTsSession {
type Request = Infallible;
}
struct RealTsDialer {
spec: TrackSpec,
samples: Vec<Sample>,
}
impl Dialer for RealTsDialer {
type Session = RealTsSession;
type Error = Infallible;
fn dial(&mut self) -> Result<RealTsSession, Infallible> {
let mut pending = VecDeque::new();
pending.push_back(SessionEvent::Established);
Ok(RealTsSession {
pending,
sent: false,
spec: self.spec.clone(),
samples: self.samples.clone(),
})
}
}
async fn run_real_ts(
route_handle: Arc<RouteHandle>,
spec: TrackSpec,
samples: Vec<Sample>,
) -> multimux::Result<()> {
let mut dialer = RealTsDialer { spec, samples };
let session = dialer
.dial()
.unwrap_or_else(|never: Infallible| match never {});
let trunk_config = TrunkConfig::new(nz(64), nz(16), nz(8), nz(64), nz(64));
let handshake = HandshakePolicy::establish_by(Timestamp::from_nanos(u64::MAX));
let mut driver: IngestDriver<RealTsSession> = IngestDriver::new(
session,
trunk_config,
handshake,
media_plane::DEFAULT_MAX_PROGRAMS,
);
let mut progress = DriverProgress::new();
driver.feed(&[], Timestamp::from_nanos(0));
advance_route(&driver, &route_handle, &mut progress);
driver.finish();
advance_route(&driver, &route_handle, &mut progress);
Ok(())
}
#[tokio::test]
async fn custom_dispatch_drives_a_driver_backed_source_and_serves_real_media() {
let (spec, samples) = real_video_track_and_samples();
assert!(
!samples.is_empty(),
"h264_aac.ts must demux to at least one video sample"
);
let mut registry = SchemeRegistry::new();
registry.register_input(
"mock-driver-backed",
Arc::new(move |ctx: InputCtx| {
let spec = spec.clone();
let samples = samples.clone();
Ok(tokio::spawn(supervise_driver(
move |route_handle| run_real_ts(route_handle, spec.clone(), samples.clone()),
ctx.store,
Backoff::production_default(),
ctx.name,
ctx.shutdown_rx,
)))
}) as InputFactory,
);
let bind_addr = reserve_tcp_addr();
let config = base_config(
bind_addr,
InputSpec::Custom {
type_tag: "mock-driver-backed".to_string(),
params: serde_json::Value::Null,
},
);
let server = tokio::spawn(serve_with_registry(config, registry));
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_bytes =
get_non_empty(&client, &format!("http://{bind_addr}/cam/init-1.mp4")).await;
let seg_uri = first_segment_uri(&playlist).to_string();
let _seg_bytes = get_non_empty(&client, &format!("http://{bind_addr}/cam/{seg_uri}")).await;
server.abort();
}
#[tokio::test]
async fn dash_two_program_track_separation() {
use std::collections::VecDeque;
use std::convert::Infallible;
struct TwoProgSession {
pending: VecDeque<SessionEvent>,
announced: bool,
}
impl Stage for TwoProgSession {
type In<'a> = &'a [u8];
type Out = SessionEvent;
type Error = Infallible;
fn demand(&self) -> Demand {
Demand::new(1)
}
fn feed(&mut self, _input: &[u8], _now: Timestamp) -> Result<(), Infallible> {
if !self.announced {
self.announced = true;
let track_1 = TrackSpec::new(
1,
90_000,
CodecConfig::Avc {
config: transmux::avc_config_from_sprop(
"Z0IAKeKQFAe2AtwEBAaQeJEV,aM48gA==",
)
.expect("valid sprop"),
width: 320,
height: 240,
},
);
let track_7 = TrackSpec::new(
7,
90_000,
CodecConfig::Avc {
config: transmux::avc_config_from_sprop(
"Z0IAKeKQFAe2AtwEBAaQeJEV,aM48gA==",
)
.expect("valid sprop"),
width: 640,
height: 480,
},
);
self.pending.push_back(SessionEvent::NewProgram {
program: ProgramId(0),
tracks: vec![track_1],
});
self.pending.push_back(SessionEvent::NewProgram {
program: ProgramId(1),
tracks: vec![track_7],
});
}
Ok(())
}
fn poll(&mut self) -> Option<SessionEvent> {
self.pending.pop_front()
}
fn next_deadline(&self) -> Option<Timestamp> {
None
}
fn on_deadline(&mut self, _now: Timestamp) {}
fn finish(&mut self) -> Result<(), Infallible> {
Ok(())
}
}
impl IngestSession for TwoProgSession {
type Request = Infallible;
}
struct TwoProgDialer;
impl Dialer for TwoProgDialer {
type Session = TwoProgSession;
type Error = Infallible;
fn dial(&mut self) -> Result<TwoProgSession, Infallible> {
let mut pending = VecDeque::new();
pending.push_back(SessionEvent::Established);
Ok(TwoProgSession {
pending,
announced: false,
})
}
}
async fn run_two_prog(route_handle: Arc<RouteHandle>) -> multimux::Result<()> {
let mut dialer = TwoProgDialer;
let session = dialer
.dial()
.unwrap_or_else(|never: Infallible| match never {});
let trunk_config = TrunkConfig::new(nz(64), nz(16), nz(8), nz(64), nz(64));
let handshake = HandshakePolicy::establish_by(Timestamp::from_nanos(u64::MAX));
let mut driver: IngestDriver<TwoProgSession> = IngestDriver::new(
session,
trunk_config,
handshake,
media_plane::DEFAULT_MAX_PROGRAMS,
);
let mut progress = DriverProgress::new();
driver.feed(&[], Timestamp::from_nanos(0));
advance_route(&driver, &route_handle, &mut progress);
driver.finish();
advance_route(&driver, &route_handle, &mut progress);
Ok(())
}
let captured_store = Arc::new(tokio::sync::Mutex::new(None::<Arc<RouteHandle>>));
let captured_store_clone = Arc::clone(&captured_store);
let mut registry = SchemeRegistry::new();
registry.register_input(
"mock-two-prog",
Arc::new(move |ctx: InputCtx| {
let store = Arc::clone(&ctx.store);
let cs = Arc::clone(&captured_store_clone);
{
let mut guard = cs.try_lock().expect("captured store not yet set");
*guard = Some(store);
}
Ok(tokio::spawn(supervise_driver(
run_two_prog,
ctx.store,
Backoff::production_default(),
ctx.name,
ctx.shutdown_rx,
)))
}) as InputFactory,
);
let bind_addr = reserve_tcp_addr();
let mut config = base_config(
bind_addr,
InputSpec::Custom {
type_tag: "mock-two-prog".to_string(),
params: serde_json::Value::Null,
},
);
config.routes[0].outputs = vec![OutputKind::Dash];
let server = tokio::spawn(serve_with_registry(config, registry));
let store_opt = {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
let guard = captured_store.try_lock().expect("captured store lock");
if guard.is_some() {
break guard.clone();
}
drop(guard);
if tokio::time::Instant::now() >= deadline {
panic!("InputCtx factory never fired within hang guard (10 s)");
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
};
let route_handle = store_opt.unwrap();
{
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
if !route_handle.track_specs(ProgramId(0)).is_empty() {
break;
}
if tokio::time::Instant::now() >= deadline {
panic!("track_specs for ProgramId(0) never populated within hang guard (10 s)");
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
let specs_p0 = route_handle.track_specs(ProgramId(0));
let specs_p1 = route_handle.track_specs(ProgramId(1));
assert_eq!(
specs_p0.len(),
1,
"ProgramId(0) must carry its own track spec (track_id=1), not empty: {specs_p0:?}"
);
assert!(
specs_p0.iter().any(|s| s.track_id == 1),
"ProgramId(0) must name track_id=1 — its assigned track, not the other's: {specs_p0:?}"
);
assert_eq!(
specs_p1.len(),
1,
"ProgramId(1) must carry its own track spec (track_id=7), not empty — \
a per-ProgramId bug that syncs only SPTS_PROGRAM_ID would leave this empty: {specs_p1:?}"
);
assert!(
specs_p1.iter().any(|s| s.track_id == 7),
"ProgramId(1) must name track_id=7 — its assigned track, not the other's: {specs_p1:?}"
);
let client = reqwest::Client::new();
let mpd_url = format!("http://{bind_addr}/cam/manifest.mpd");
let resp = client
.get(&mpd_url)
.send()
.await
.unwrap_or_else(|e| panic!("GET {mpd_url} failed: {e}"));
assert_eq!(
resp.status(),
reqwest::StatusCode::OK,
"manifest.mpd must return 200 after track specs are populated"
);
let mpd_body = resp.text().await.unwrap_or_default();
assert!(
mpd_body.contains(r#"id="1""#),
"SPTS manifest must name track_id=1: {mpd_body}"
);
server.abort();
}
}
async fn poll_until_200_with(
client: &reqwest::Client,
url: &str,
predicate: impl Fn(&str) -> bool,
) -> String {
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
loop {
match client.get(url).send().await {
Ok(resp) if resp.status() == reqwest::StatusCode::OK => {
let body = resp
.text()
.await
.unwrap_or_else(|e| panic!("GET {url}: reading body failed: {e}"));
if predicate(&body) {
return body;
}
}
Ok(resp) => {
let _ = resp;
}
Err(_) => { }
}
if tokio::time::Instant::now() >= deadline {
panic!("GET {url} never returned 200 with the expected body within hang guard (20 s)");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[tokio::test]
async fn dash_manifest_served_without_explicit_set_track_specs() {
let bind_addr = reserve_tcp_addr();
let udp_addr = reserve_udp_addr();
let mut config = base_config(
bind_addr,
InputSpec::TsUdp {
addr: udp_addr.to_string(),
multicast_group: None,
},
);
config.routes[0].outputs = vec![OutputKind::Dash];
let server = tokio::spawn(serve_with_registry(config, SchemeRegistry::new()));
let ts_bytes = std::fs::read(fixture_path()).expect("h264_aac.ts fixture must exist");
let stop = Arc::new(AtomicBool::new(false));
let sender_stop = Arc::clone(&stop);
let sender = tokio::net::UdpSocket::bind("127.0.0.1:0")
.await
.expect("bind sender");
let send_task = tokio::spawn(async move {
while !sender_stop.load(Ordering::Relaxed) {
for chunk in ts_bytes.chunks(7 * 188) {
let _ = sender.send_to(chunk, udp_addr).await;
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
});
let client = reqwest::Client::new();
let mpd_url = format!("http://{bind_addr}/cam/manifest.mpd");
let mpd_body = poll_until_200_with(&client, &mpd_url, |body| {
body.contains("<MPD")
&& body.contains(r#"xmlns="urn:mpeg:dash:schema:mpd:2011""#)
&& body.contains(r#"type="dynamic""#)
&& body.contains("<Representation")
})
.await;
stop.store(true, Ordering::Relaxed);
assert!(
mpd_body.contains("<MPD"),
"manifest.mpd must be well-formed XML: {mpd_body}"
);
assert!(
mpd_body.contains(r#"xmlns="urn:mpeg:dash:schema:mpd:2011""#),
"{mpd_body}"
);
assert!(mpd_body.contains(r#"type="dynamic""#), "{mpd_body}");
assert!(
mpd_body.contains("<Representation"),
"manifest must describe at least one Representation — \
the route's real H.264 track from the fixture: {mpd_body}"
);
send_task.abort();
server.abort();
}
#[tokio::test]
async fn ll_dash_manifest_served_without_explicit_set_track_specs() {
let bind_addr = reserve_tcp_addr();
let udp_addr = reserve_udp_addr();
let mut config = base_config(
bind_addr,
InputSpec::TsUdp {
addr: udp_addr.to_string(),
multicast_group: None,
},
);
config.routes[0].outputs = vec![OutputKind::LlDash];
let server = tokio::spawn(serve_with_registry(config, SchemeRegistry::new()));
let ts_bytes = std::fs::read(fixture_path()).expect("h264_aac.ts fixture must exist");
let stop = Arc::new(AtomicBool::new(false));
let sender_stop = Arc::clone(&stop);
let sender = tokio::net::UdpSocket::bind("127.0.0.1:0")
.await
.expect("bind sender");
let send_task = tokio::spawn(async move {
while !sender_stop.load(Ordering::Relaxed) {
for chunk in ts_bytes.chunks(7 * 188) {
let _ = sender.send_to(chunk, udp_addr).await;
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
});
let client = reqwest::Client::new();
let mpd_url = format!("http://{bind_addr}/cam/manifest-ll.mpd");
let mpd_body = poll_until_200_with(&client, &mpd_url, |body| {
body.contains("<MPD")
&& body.contains(r#"xmlns="urn:mpeg:dash:schema:mpd:2011""#)
&& body.contains(r#"type="dynamic""#)
&& body.contains("<Representation")
})
.await;
stop.store(true, Ordering::Relaxed);
assert!(
mpd_body.contains("<MPD"),
"manifest-ll.mpd must be well-formed XML: {mpd_body}"
);
assert!(
mpd_body.contains(r#"xmlns="urn:mpeg:dash:schema:mpd:2011""#),
"{mpd_body}"
);
assert!(mpd_body.contains(r#"type="dynamic""#), "{mpd_body}");
assert!(
mpd_body.contains("<Representation"),
"LL-DASH manifest must describe at least one Representation: {mpd_body}"
);
send_task.abort();
server.abort();
}
#[tokio::test]
async fn ts_udp_dash_manifest_returns_503_before_tracks_are_known() {
let bind_addr = reserve_tcp_addr();
let udp_addr = reserve_udp_addr();
let mut config = base_config(
bind_addr,
InputSpec::TsUdp {
addr: udp_addr.to_string(),
multicast_group: None,
},
);
config.routes[0].outputs = vec![OutputKind::Dash];
let server = tokio::spawn(serve_with_registry(config, SchemeRegistry::new()));
tokio::time::sleep(Duration::from_millis(200)).await;
let client = reqwest::Client::new();
let mpd_url = format!("http://{bind_addr}/cam/manifest.mpd");
let resp = client
.get(&mpd_url)
.send()
.await
.unwrap_or_else(|e| panic!("GET {mpd_url} failed: {e}"));
assert_eq!(
resp.status(),
reqwest::StatusCode::SERVICE_UNAVAILABLE,
"manifest.mpd must return 503 until at least one program \
with known tracks is announced — a route with no ingest yet \
must not return 200: status={}",
resp.status()
);
server.abort();
}