use std::collections::HashMap;
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use multimux::origin::{AppState, router};
use multimux::output::Output;
use multimux::output::llhls::LlHlsOutput;
use multimux::route::RouteHandle;
use transmux::avc_config_from_sprop;
use transmux::ll_hls::{LlHlsSegmenter, PartInfo};
use transmux::pipeline::{CodecConfig, Sample, TrackSpec};
use transmux::validate::{Severity, validate_init_segment, validate_media_segment};
const SPROP: &str = "Z0IAKeKQFAe2AtwEBAaQeJEV,aM48gA==";
const VIDEO_TIMESCALE: u32 = 90_000;
const FRAME_DUR: u32 = VIDEO_TIMESCALE / 30;
const TARGET_DURATION_SECS: f64 = 1.0;
const PART_TARGET_MS: u32 = 500;
fn video_track_spec() -> TrackSpec {
let config = avc_config_from_sprop(SPROP).expect("valid sprop");
TrackSpec::new(
1,
VIDEO_TIMESCALE,
CodecConfig::Avc {
config,
width: 0,
height: 0,
},
)
}
fn errors_only(
issues: &[transmux::validate::ConformanceIssue],
) -> Vec<&transmux::validate::ConformanceIssue> {
issues
.iter()
.filter(|i| i.severity == Severity::Error)
.collect()
}
fn first_segment_uri(playlist: &str) -> Option<&str> {
let start = playlist.find("seg-")?;
let rest = &playlist[start..];
let end = rest.find(".m4s")? + ".m4s".len();
Some(&rest[..end])
}
async fn body_string(resp: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
async fn body_bytes(resp: axum::response::Response) -> Vec<u8> {
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec()
}
fn get(uri: &str) -> Request<Body> {
Request::builder()
.uri(uri)
.body(Body::empty())
.expect("well-formed GET request")
}
fn feed_via_segmenter(
store: &RouteHandle,
specs: Vec<TrackSpec>,
batches: Vec<Vec<(u32, Sample)>>,
) {
let program = media_plane::ProgramId(0);
store.publish_new_program(program);
let mut seg = LlHlsSegmenter::with_part_target(
specs,
transmux::VIDEO_CLOCK_RATE,
TARGET_DURATION_SECS,
PART_TARGET_MS,
)
.expect("segmenter builds");
store.set_init(program, seg.init_segment().expect("init segment builds"));
for batch in batches {
for (track_id, sample) in batch {
seg.push(track_id, sample).expect("push succeeds");
}
for part in seg.take_ready_parts() {
store.add_part(program, part);
}
for segment in seg.take_ready_segments() {
store.add_segment(program, segment);
}
}
seg.flush().expect("flush succeeds");
for part in seg.take_ready_parts() {
store.add_part(program, part);
}
for segment in seg.take_ready_segments() {
store.add_segment(program, segment);
}
}
#[tokio::test]
async fn end_to_end_pipeline_serves_valid_llhls() {
let store = Arc::new(RouteHandle::new(TARGET_DURATION_SECS, PART_TARGET_MS, 8));
let specs = vec![video_track_spec()];
let mut batches = Vec::new();
for i in 0..120u32 {
let is_sync = i % 30 == 0;
let data = vec![0xAAu8.wrapping_add((i % 251) as u8); 64];
let sample = Sample::new(
data,
Some(i64::from(i) * i64::from(FRAME_DUR)),
Some(i64::from(i) * i64::from(FRAME_DUR)),
Some(FRAME_DUR),
is_sync,
);
batches.push(vec![(1u32, sample)]);
}
feed_via_segmenter(&store, specs, batches);
let mut streams = HashMap::new();
streams.insert(
"cam".to_string(),
(
store.clone(),
vec![Arc::new(LlHlsOutput::default()) as Arc<dyn Output>],
),
);
let app = router(Arc::new(AppState::new(streams)));
let resp = app
.clone()
.oneshot(get("/cam/media.m3u8"))
.await
.expect("router call");
assert_eq!(resp.status(), StatusCode::OK);
let playlist = body_string(resp).await;
assert!(playlist.contains("#EXT-X-PART"), "playlist: {playlist}");
assert!(playlist.contains("#EXT-X-PART-INF"), "playlist: {playlist}");
assert!(
playlist.contains("#EXT-X-SERVER-CONTROL"),
"playlist: {playlist}"
);
let resp = app
.clone()
.oneshot(get("/cam/init-1.mp4"))
.await
.expect("router call");
assert_eq!(resp.status(), StatusCode::OK);
let init_bytes = body_bytes(resp).await;
assert!(!init_bytes.is_empty(), "init segment body non-empty");
let init_issues = validate_init_segment(&init_bytes);
let init_errors = errors_only(&init_issues);
assert!(
init_errors.is_empty(),
"init segment must have no conformance errors, got: {init_errors:?}"
);
let seg_uri = first_segment_uri(&playlist)
.unwrap_or_else(|| panic!("no seg-*.m4s URI in playlist: {playlist}"));
let resp = app
.clone()
.oneshot(get(&format!("/cam/{seg_uri}")))
.await
.expect("router call");
assert_eq!(resp.status(), StatusCode::OK, "segment URI: {seg_uri}");
let seg_bytes = body_bytes(resp).await;
assert!(!seg_bytes.is_empty(), "segment body non-empty");
let seg_issues = validate_media_segment(&seg_bytes);
let seg_errors = errors_only(&seg_issues);
assert!(
seg_errors.is_empty(),
"media segment must have no conformance errors, got: {seg_errors:?}"
);
let resp = app
.oneshot(get("/ghost/media.m3u8"))
.await
.expect("router call");
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
fn part(seq: u32, idx: u32) -> PartInfo {
PartInfo {
bytes: vec![0x10 + idx as u8; 4],
duration: 0.5,
independent: idx == 0,
segment_seq: seq,
part_index: idx,
}
}
#[tokio::test]
async fn blocking_reload_resolves_when_part_arrives() {
let store = Arc::new(RouteHandle::new(4.0, 500, 8));
let program = media_plane::ProgramId(0);
store.publish_new_program(program);
store.set_init(program, vec![0xAA; 8]);
store.add_part(program, part(1, 0));
let mut streams = HashMap::new();
streams.insert(
"cam".to_string(),
(
store.clone(),
vec![Arc::new(LlHlsOutput::default()) as Arc<dyn Output>],
),
);
let app = router(Arc::new(AppState::new(streams)));
let app_for_task = app.clone();
let handle = tokio::spawn(async move {
app_for_task
.oneshot(get("/cam/media.m3u8?_HLS_msn=1&_HLS_part=1"))
.await
.expect("router call")
});
for _ in 0..8 {
tokio::task::yield_now().await;
}
store.add_part(program, part(1, 1));
let resp = tokio::time::timeout(std::time::Duration::from_millis(500), handle)
.await
.expect("blocking reload must resolve promptly on the watch wakeup, not the 5s timeout fallback")
.expect("blocking request task did not panic");
assert_eq!(
resp.status(),
StatusCode::OK,
"blocking reload must resolve (not timeout/404) once the awaited part lands"
);
let playlist = body_string(resp).await;
let real_part_line = "#EXT-X-PART:DURATION=0.5,URI=\"part-1-1.1.m4s\"";
assert!(
playlist.contains(real_part_line),
"resolved playlist must include the real #EXT-X-PART line for the \
newly-arrived part (not just the preload-hint URI): {playlist}"
);
}