use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::extract::{Path, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use futures_util::stream;
use ll_hls_runtime::server::LlHlsRequest;
use crate::http::{self, BLOCKING_RELOAD_TIMEOUT};
use crate::route::{ProgramServing, RouteHandle};
pub(crate) const MP4_CONTENT_TYPE: &str = "video/mp4";
const SEGMENT_ABUSE_FUTURE_BOUND: u32 = 4;
pub(crate) struct BlockingRequestGuard;
impl BlockingRequestGuard {
pub(crate) fn new() -> Self {
metrics::gauge!(crate::prometheus::ACTIVE_BLOCKING_REQUESTS).increment(1.0);
BlockingRequestGuard
}
}
impl Drop for BlockingRequestGuard {
fn drop(&mut self) {
metrics::gauge!(crate::prometheus::ACTIVE_BLOCKING_REQUESTS).decrement(1.0);
}
}
pub(crate) fn router(route: Arc<RouteHandle>) -> Router {
Router::new()
.route("/:file", get(dynamic_file).options(cors_preflight))
.with_state(route)
}
pub(crate) async fn cors_preflight() -> StatusCode {
StatusCode::NO_CONTENT
}
async fn dynamic_file(State(route): State<Arc<RouteHandle>>, Path(file): Path<String>) -> Response {
let serving = match http::resolve_route_program(&route) {
Ok(serving) => serving,
Err(resp) => return *resp,
};
let trunk = serving.trunk();
let ll_hls = serving.ll_hls();
let resp = http::resolve_blocking(
&trunk,
ll_hls.as_ref(),
LlHlsRequest::Resource { name: file.clone() },
BLOCKING_RELOAD_TIMEOUT,
BlockingRequestGuard::new,
)
.await;
match http::into_response(resp, StatusCode::NOT_FOUND, |body| {
resource_body_response(body)
}) {
resp if resp.status() == StatusCode::NOT_FOUND => {
if let Some((track, seq)) = parse_segment_filename(&file) {
if let Some(resp) = stream_in_progress_segment(serving, track, seq).await {
return resp;
}
}
StatusCode::NOT_FOUND.into_response()
}
resp => resp,
}
}
fn resource_body_response(body: ll_hls_runtime::server::LlHlsBody) -> Response {
match body {
ll_hls_runtime::server::LlHlsBody::Resource(bytes) => {
([(header::CONTENT_TYPE, MP4_CONTENT_TYPE)], bytes).into_response()
}
ll_hls_runtime::server::LlHlsBody::Playlist(_) => StatusCode::NOT_FOUND.into_response(),
_ => StatusCode::NOT_FOUND.into_response(),
}
}
fn parse_segment_filename(file: &str) -> Option<(&str, u32)> {
let rest = file.strip_prefix("seg-")?.strip_suffix(".m4s")?;
let (track, seq) = rest.split_once('-')?;
track.parse::<u32>().ok()?;
Some((track, seq.parse().ok()?))
}
async fn stream_in_progress_segment(
serving: Arc<ProgramServing>,
track: &str,
seq: u32,
) -> Option<Response> {
let (in_progress_seg_seq, _) = serving.latest_progress();
if seq > in_progress_seg_seq.saturating_add(SEGMENT_ABUSE_FUTURE_BOUND) {
return None;
}
let track = track.to_string();
let first = fetch_part(&serving, &track, seq, 0).await;
let first_bytes = first?;
let cursor = PartCursor {
serving,
track,
seq,
next_index: 1,
pending_first: Some(first_bytes),
};
let body_stream = stream::unfold(cursor, |mut cursor| async move {
if let Some(bytes) = cursor.pending_first.take() {
return Some((Ok::<_, std::io::Error>(bytes), cursor));
}
match fetch_part(
&cursor.serving,
&cursor.track,
cursor.seq,
cursor.next_index,
)
.await
{
Some(bytes) => {
cursor.next_index += 1;
Some((Ok(bytes), cursor))
}
None => None,
}
});
let mut response = Response::new(Body::from_stream(body_stream));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static(MP4_CONTENT_TYPE),
);
Some(response)
}
async fn fetch_part(
serving: &Arc<ProgramServing>,
track: &str,
seq: u32,
idx: u32,
) -> Option<bytes::Bytes> {
let trunk = serving.trunk();
let ll_hls = serving.ll_hls();
let name = format!("part-{track}-{seq}.{idx}.m4s");
let resp = http::resolve_blocking(
&trunk,
ll_hls.as_ref(),
LlHlsRequest::Resource { name },
BLOCKING_RELOAD_TIMEOUT,
BlockingRequestGuard::new,
)
.await;
match resp {
media_plane::egress::EgressResponse::Ready {
body: ll_hls_runtime::server::LlHlsBody::Resource(bytes),
..
} => Some(bytes),
_ => None,
}
}
struct PartCursor {
serving: Arc<ProgramServing>,
track: String,
seq: u32,
next_index: u32,
pending_first: Option<bytes::Bytes>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::route::RouteHandle;
use transmux::ll_hls::{PartInfo, SegmentInfo};
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,
}
}
fn seg(seq: u32) -> SegmentInfo {
SegmentInfo {
bytes: vec![0x20 + seq as u8; 8],
duration: 4.0,
segment_seq: seq,
part_count: 2,
}
}
fn make_route() -> Arc<RouteHandle> {
let route = Arc::new(RouteHandle::new(4.0, 500, 4));
route.publish_new_program(crate::route::SPTS_PROGRAM_ID);
route.set_init(crate::route::SPTS_PROGRAM_ID, vec![0xAA; 8]);
route.add_segment(crate::route::SPTS_PROGRAM_ID, seg(1));
route.add_part(crate::route::SPTS_PROGRAM_ID, part(2, 0));
route.add_part(crate::route::SPTS_PROGRAM_ID, part(2, 1));
route
}
async fn body_bytes(resp: Response) -> Vec<u8> {
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec()
}
#[tokio::test]
async fn dynamic_file_init_present() {
let route = make_route();
let resp = dynamic_file(State(route), Path("init-1.mp4".to_string())).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_bytes(resp).await, vec![0xAA; 8]);
}
#[tokio::test]
async fn dynamic_file_segment_present_and_absent() {
let route = make_route();
let ok = dynamic_file(State(route.clone()), Path("seg-1-1.m4s".to_string())).await;
assert_eq!(ok.status(), StatusCode::OK);
assert_eq!(body_bytes(ok).await, vec![0x21; 8]);
let missing = dynamic_file(State(route), Path("seg-1-99.m4s".to_string())).await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn dynamic_file_part_present() {
let route = make_route();
let resp = dynamic_file(State(route), Path("part-1-2.0.m4s".to_string())).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_bytes(resp).await, vec![0x10; 4]);
}
#[tokio::test]
async fn dynamic_file_part_blocks_until_available_then_serves() {
let route = make_route();
let route_for_task = route.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
route_for_task.add_part(crate::route::SPTS_PROGRAM_ID, part(2, 2));
});
let resp = dynamic_file(State(route), Path("part-1-2.2.m4s".to_string())).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"part request must block until the part is produced, not 404"
);
assert_eq!(body_bytes(resp).await, vec![0x12; 4]); }
#[tokio::test]
async fn dynamic_file_part_404_promptly_when_segment_closes_without_it() {
let route = make_route();
let route_for_task = route.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
route_for_task.add_segment(crate::route::SPTS_PROGRAM_ID, seg(2)); });
let started = std::time::Instant::now();
let resp = dynamic_file(State(route), Path("part-1-2.9.m4s".to_string())).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
assert!(
started.elapsed() < BLOCKING_RELOAD_TIMEOUT,
"must 404 promptly on segment close, not wait out the timeout"
);
}
#[tokio::test]
async fn dynamic_file_part_served_after_close() {
let route = make_route();
route.add_segment(crate::route::SPTS_PROGRAM_ID, seg(2)); let resp = dynamic_file(State(route), Path("part-1-2.1.m4s".to_string())).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"a just-closed segment's part must still be served, not 404"
);
assert_eq!(body_bytes(resp).await, vec![0x11; 4]); }
#[tokio::test]
async fn dynamic_file_part_of_old_closed_segment_404() {
let route = make_route();
let resp = dynamic_file(State(route), Path("part-1-1.0.m4s".to_string())).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn dynamic_file_unmatched_filename_404() {
let route = make_route();
let resp = dynamic_file(State(route), Path("not-a-thing.txt".to_string())).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn dynamic_file_in_progress_segment_streams_concatenated_parts_and_completes_on_close() {
let route = Arc::new(RouteHandle::new(4.0, 500, 4));
route.publish_new_program(crate::route::SPTS_PROGRAM_ID);
route.set_init(crate::route::SPTS_PROGRAM_ID, vec![0xAA; 8]);
route.add_part(crate::route::SPTS_PROGRAM_ID, part(2, 0));
let route_for_task = route.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
route_for_task.add_part(crate::route::SPTS_PROGRAM_ID, part(2, 1));
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
route_for_task.add_segment(crate::route::SPTS_PROGRAM_ID, seg(2));
});
let resp = dynamic_file(State(route), Path("seg-1-2.m4s".to_string())).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"an in-progress whole-segment request must stream, not 404"
);
assert_eq!(
body_bytes(resp).await,
[vec![0x10; 4], vec![0x11; 4]].concat(),
"streamed body must be part 0 + part 1 concatenated in order, \
including the part that only landed after the response started"
);
}
#[tokio::test]
async fn dynamic_file_future_segment_within_bound_blocks_then_streams_once_started() {
let route = make_route();
let route_for_start = route.clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
route_for_start.add_part(
crate::route::SPTS_PROGRAM_ID,
PartInfo {
bytes: vec![0x77; 4],
duration: 0.5,
independent: true,
segment_seq: 3,
part_index: 0,
},
);
});
let started = std::time::Instant::now();
let resp = dynamic_file(State(route.clone()), Path("seg-1-3.m4s".to_string())).await;
assert_eq!(
resp.status(),
StatusCode::OK,
"a near-future segment must be waited for, not rejected"
);
assert!(
started.elapsed() < BLOCKING_RELOAD_TIMEOUT,
"must resolve once the part lands, not idle out the full timeout"
);
route.add_segment(crate::route::SPTS_PROGRAM_ID, seg(3));
assert_eq!(body_bytes(resp).await, vec![0x77; 4]);
}
#[tokio::test]
async fn dynamic_file_far_future_segment_beyond_abuse_bound_404_promptly() {
let route = make_route();
let started = std::time::Instant::now();
let resp = dynamic_file(State(route), Path("seg-1-99.m4s".to_string())).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
assert!(
started.elapsed() < std::time::Duration::from_millis(500),
"an abusive far-future segment number must 404 promptly, not block: {:?}",
started.elapsed()
);
}
#[tokio::test]
async fn dynamic_file_closed_segment_still_served_whole_not_streamed() {
let route = make_route();
let resp = dynamic_file(State(route), Path("seg-1-1.m4s".to_string())).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_bytes(resp).await, vec![0x21; 8]);
}
#[tokio::test]
async fn dynamic_file_not_yet_announced_is_503_not_404() {
let route = Arc::new(RouteHandle::new(4.0, 500, 4));
let resp = dynamic_file(State(route), Path("init-1.mp4".to_string())).await;
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"a route with no program announced yet must be 503 (not ready), not 404 (gone)"
);
}
}