use std::sync::Arc;
use std::time::Duration;
use axum::extract::{Path, Query, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use crate::origin::AppState;
use crate::store::StreamStore;
pub const DEFAULT_TRACK_ID: u32 = 1;
const PLACEHOLDER_BANDWIDTH_BPS: u64 = 5_000_000;
const BLOCKING_RELOAD_TIMEOUT: Duration = Duration::from_secs(5);
const MEDIA_PLAYLIST_CONTENT_TYPE: &str = "application/vnd.apple.mpegurl";
const MP4_CONTENT_TYPE: &str = "video/mp4";
pub async fn master_playlist(
State(state): State<Arc<AppState>>,
Path(stream): Path<String>,
) -> Response {
if !state.streams.contains_key(&stream) {
return StatusCode::NOT_FOUND.into_response();
}
let body =
format!("#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH={PLACEHOLDER_BANDWIDTH_BPS}\nmedia.m3u8\n");
([(header::CONTENT_TYPE, MEDIA_PLAYLIST_CONTENT_TYPE)], body).into_response()
}
#[derive(Debug, Default, Deserialize)]
pub struct BlockingReloadQuery {
#[serde(rename = "_HLS_msn")]
pub hls_msn: Option<u64>,
#[serde(rename = "_HLS_part")]
pub hls_part: Option<u32>,
}
async fn wait_for_progress(store: &StreamStore, msn: u64, part: u32) {
let mut rx = store.subscribe();
let wait = async {
loop {
let (in_progress_seg_seq, part_count) = store.latest_progress();
let satisfied = u64::from(in_progress_seg_seq) > msn
|| (u64::from(in_progress_seg_seq) == msn && part_count > part);
if satisfied {
return;
}
if rx.changed().await.is_err() {
return;
}
}
};
let _ = tokio::time::timeout(BLOCKING_RELOAD_TIMEOUT, wait).await;
}
async fn wait_for_part(store: &StreamStore, seq: u32, idx: u32) -> Option<Vec<u8>> {
let mut rx = store.subscribe();
let wait = async {
loop {
if let Some(bytes) = store.part_bytes(seq, idx) {
return Some(bytes);
}
let (in_progress_seg_seq, _) = store.latest_progress();
if in_progress_seg_seq > seq || store.segment_bytes(seq).is_some() {
return None;
}
if rx.changed().await.is_err() {
return None;
}
}
};
tokio::time::timeout(BLOCKING_RELOAD_TIMEOUT, wait)
.await
.ok()
.flatten()
}
pub async fn media_playlist(
State(state): State<Arc<AppState>>,
Path(stream): Path<String>,
Query(q): Query<BlockingReloadQuery>,
) -> Response {
let Some(store) = state.streams.get(&stream) else {
return StatusCode::NOT_FOUND.into_response();
};
if let Some(msn) = q.hls_msn {
let part = q.hls_part.unwrap_or(0);
wait_for_progress(store, msn, part).await;
}
let body = store.media_playlist_m3u8(DEFAULT_TRACK_ID);
([(header::CONTENT_TYPE, MEDIA_PLAYLIST_CONTENT_TYPE)], body).into_response()
}
pub async fn dynamic_file(
State(state): State<Arc<AppState>>,
Path((stream, file)): Path<(String, String)>,
) -> Response {
let Some(store) = state.streams.get(&stream) else {
return StatusCode::NOT_FOUND.into_response();
};
if let Some((seq, idx)) = parse_part(&file) {
return match wait_for_part(store, seq, idx).await {
Some(bytes) => ([(header::CONTENT_TYPE, MP4_CONTENT_TYPE)], bytes).into_response(),
None => StatusCode::NOT_FOUND.into_response(),
};
}
match resolve_file(store, &file) {
Some(bytes) => ([(header::CONTENT_TYPE, MP4_CONTENT_TYPE)], bytes).into_response(),
None => StatusCode::NOT_FOUND.into_response(),
}
}
fn parse_part(file: &str) -> Option<(u32, u32)> {
let rest = file.strip_prefix("part-")?.strip_suffix(".m4s")?;
let (track_seq, idx) = rest.rsplit_once('.')?;
let (track, seq) = track_seq.split_once('-')?;
track.parse::<u32>().ok()?;
Some((seq.parse().ok()?, idx.parse().ok()?))
}
fn resolve_file(store: &StreamStore, file: &str) -> Option<Vec<u8>> {
if let Some(rest) = file.strip_prefix("init-") {
let track = rest.strip_suffix(".mp4")?;
track.parse::<u32>().ok()?;
return store.init_bytes();
}
if let Some(rest) = file.strip_prefix("seg-") {
let rest = rest.strip_suffix(".m4s")?;
let (track, seq) = rest.split_once('-')?;
track.parse::<u32>().ok()?;
let seq: u32 = seq.parse().ok()?;
return store.segment_bytes(seq);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::origin::AppState;
use std::collections::HashMap;
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_state() -> Arc<AppState> {
let store = Arc::new(StreamStore::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));
let mut streams = HashMap::new();
streams.insert("cam1".to_string(), store);
Arc::new(AppState { streams })
}
async fn body_string(resp: 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: Response) -> Vec<u8> {
axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap()
.to_vec()
}
#[tokio::test]
async fn master_playlist_known_stream_ok() {
let state = make_state();
let resp = master_playlist(State(state), Path("cam1".to_string())).await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(body.contains("#EXTM3U"));
assert!(body.contains("#EXT-X-STREAM-INF"));
assert!(body.contains("media.m3u8"));
}
#[tokio::test]
async fn master_playlist_unknown_stream_404() {
let state = make_state();
let resp = master_playlist(State(state), Path("nope".to_string())).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn media_playlist_no_query_renders_now() {
let state = make_state();
let resp = media_playlist(
State(state),
Path("cam1".to_string()),
Query(BlockingReloadQuery::default()),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(body.contains("#EXT-X-PART"), "body: {body}");
}
#[tokio::test]
async fn media_playlist_unknown_stream_404() {
let state = make_state();
let resp = media_playlist(
State(state),
Path("nope".to_string()),
Query(BlockingReloadQuery::default()),
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn media_playlist_already_satisfied_blocking_request_resolves_immediately() {
let state = make_state();
let resp = media_playlist(
State(state),
Path("cam1".to_string()),
Query(BlockingReloadQuery {
hls_msn: Some(1),
hls_part: Some(0),
}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn media_playlist_already_satisfied_same_msn_lower_part() {
let state = make_state();
let resp = media_playlist(
State(state),
Path("cam1".to_string()),
Query(BlockingReloadQuery {
hls_msn: Some(2),
hls_part: Some(1),
}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn dynamic_file_init_present() {
let state = make_state();
let resp = dynamic_file(
State(state),
Path(("cam1".to_string(), "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 state = make_state();
let ok = dynamic_file(
State(state.clone()),
Path(("cam1".to_string(), "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(state),
Path(("cam1".to_string(), "seg-1-99.m4s".to_string())),
)
.await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn dynamic_file_part_present() {
let state = make_state();
let resp = dynamic_file(
State(state),
Path(("cam1".to_string(), "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 state = make_state();
let store = state.streams.get("cam1").unwrap().clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
store.add_part(part(2, 2));
});
let resp = dynamic_file(
State(state),
Path(("cam1".to_string(), "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 state = make_state();
let store = state.streams.get("cam1").unwrap().clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
store.add_segment(seg(2)); });
let started = std::time::Instant::now();
let resp = dynamic_file(
State(state),
Path(("cam1".to_string(), "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 state = make_state();
let store = state.streams.get("cam1").unwrap().clone();
store.add_segment(seg(2)); let resp = dynamic_file(
State(state),
Path(("cam1".to_string(), "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 state = make_state();
let resp = dynamic_file(
State(state),
Path(("cam1".to_string(), "part-1-1.0.m4s".to_string())),
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn dynamic_file_unknown_stream_404() {
let state = make_state();
let resp = dynamic_file(
State(state),
Path(("nope".to_string(), "init-1.mp4".to_string())),
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn dynamic_file_unmatched_filename_404() {
let state = make_state();
let resp = dynamic_file(
State(state),
Path(("cam1".to_string(), "not-a-thing.txt".to_string())),
)
.await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
}