use std::sync::Arc;
use std::time::Duration;
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::ResourceOutcome;
use crate::store::MediaStore;
pub(crate) const BLOCKING_RELOAD_TIMEOUT: Duration = Duration::from_secs(5);
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(store: Arc<MediaStore>) -> Router {
Router::new()
.route("/:file", get(dynamic_file).options(cors_preflight))
.with_state(store)
}
pub(crate) async fn cors_preflight() -> StatusCode {
StatusCode::NO_CONTENT
}
async fn resource_blocking(store: &MediaStore, name: &str) -> ResourceOutcome {
match store.resolve_resource(name) {
ResourceOutcome::WouldBlock => {}
terminal => return terminal,
}
let _guard = BlockingRequestGuard::new();
let wait = async {
loop {
let listener = store.listen();
match store.resolve_resource(name) {
ResourceOutcome::WouldBlock => {}
terminal => return terminal,
}
listener.await;
}
};
tokio::time::timeout(BLOCKING_RELOAD_TIMEOUT, wait)
.await
.unwrap_or(ResourceOutcome::NotFound)
}
async fn dynamic_file(State(store): State<Arc<MediaStore>>, Path(file): Path<String>) -> Response {
match resource_blocking(&store, &file).await {
ResourceOutcome::Ready { bytes, .. } => {
([(header::CONTENT_TYPE, MP4_CONTENT_TYPE)], bytes).into_response()
}
ResourceOutcome::NotFound => {
if let Some((track, seq)) = parse_segment_filename(&file) {
if let Some(resp) = stream_in_progress_segment(store, track, seq).await {
return resp;
}
}
StatusCode::NOT_FOUND.into_response()
}
ResourceOutcome::WouldBlock => 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(
store: Arc<MediaStore>,
track: &str,
seq: u32,
) -> Option<Response> {
let (in_progress_seg_seq, _) = store.latest_progress();
if seq > in_progress_seg_seq.saturating_add(SEGMENT_ABUSE_FUTURE_BOUND) {
return None;
}
let track = track.to_string();
let first = resource_blocking(&store, &format!("part-{track}-{seq}.0.m4s")).await;
let first_bytes = match first {
ResourceOutcome::Ready { bytes, .. } => bytes,
_ => return None,
};
let cursor = PartCursor {
store,
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));
}
let name = format!(
"part-{}-{}.{}.m4s",
cursor.track, cursor.seq, cursor.next_index
);
match resource_blocking(&cursor.store, &name).await {
ResourceOutcome::Ready { bytes, .. } => {
cursor.next_index += 1;
Some((Ok(bytes), cursor))
}
_ => 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)
}
struct PartCursor {
store: Arc<MediaStore>,
track: String,
seq: u32,
next_index: u32,
pending_first: Option<Vec<u8>>,
}
#[cfg(test)]
mod tests {
use super::*;
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_store() -> Arc<MediaStore> {
let store = Arc::new(MediaStore::new(4.0, 500, 4));
store.set_init(vec![0xAA; 8]);
store.add_segment(seg(1));
store.add_part(part(2, 0));
store.add_part(part(2, 1));
store
}
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 store = make_store();
let resp = dynamic_file(State(store), 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 store = make_store();
let ok = dynamic_file(State(store.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(store), Path("seg-1-99.m4s".to_string())).await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn dynamic_file_part_present() {
let store = make_store();
let resp = dynamic_file(State(store), 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 store = make_store();
let store_for_task = store.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
store_for_task.add_part(part(2, 2));
});
let resp = dynamic_file(State(store), 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 store = make_store();
let store_for_task = store.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
store_for_task.add_segment(seg(2)); });
let started = std::time::Instant::now();
let resp = dynamic_file(State(store), 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_from_recent_after_close() {
let store = make_store();
store.add_segment(seg(2)); let resp = dynamic_file(State(store), 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_segment_404() {
let store = make_store();
let resp = dynamic_file(State(store), 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 store = make_store();
let resp = dynamic_file(State(store), 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 store = Arc::new(MediaStore::new(4.0, 500, 4));
store.set_init(vec![0xAA; 8]);
store.add_part(part(2, 0));
let store_for_task = store.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
store_for_task.add_part(part(2, 1));
tokio::time::sleep(Duration::from_millis(50)).await;
store_for_task.add_segment(seg(2));
});
let resp = dynamic_file(State(store), 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 store = make_store();
let store_for_start = store.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
store_for_start.add_part(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(store.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"
);
store.add_segment(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 store = make_store();
let started = std::time::Instant::now();
let resp = dynamic_file(State(store), Path("seg-1-99.m4s".to_string())).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
assert!(
started.elapsed() < 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 store = make_store();
let resp = dynamic_file(State(store), Path("seg-1-1.m4s".to_string())).await;
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(body_bytes(resp).await, vec![0x21; 8]);
}
}