use std::sync::Arc;
use std::time::Duration;
use axum::Router;
use axum::extract::{Path, RawQuery, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use bytes::Bytes;
use super::Server;
use crate::export::store::SegmentStore;
const M3U8: &str = "application/vnd.apple.mpegurl";
const MP4: &str = "video/mp4";
const PLAYLIST_CACHE: &str = "no-cache";
const MEDIA_CACHE: &str = "public, max-age=60";
const READY_TIMEOUT: Duration = Duration::from_secs(5);
const BLOCK_TIMEOUT: Duration = Duration::from_secs(10);
enum Endpoint {
Master,
Media {
rendition: String,
},
Init {
rendition: String,
},
Segment {
rendition: String,
sequence: u64,
},
Part {
rendition: String,
sequence: u64,
index: usize,
},
}
struct Route {
broadcast: String,
endpoint: Endpoint,
}
impl Route {
fn parse(path: &str) -> Option<Self> {
let parts: Vec<&str> = path.split('/').collect();
let count = parts.len();
let at = |i: usize| -> Option<&str> { parts.get(count.checked_sub(1 + i)?).copied() };
let (trailing, endpoint) = match at(0)? {
"master.m3u8" => (1, Endpoint::Master),
"media.m3u8" => (
2,
Endpoint::Media {
rendition: at(1)?.to_string(),
},
),
"init.mp4" => (
2,
Endpoint::Init {
rendition: at(1)?.to_string(),
},
),
file => {
let file = file.strip_suffix(".m4s")?;
if at(1)? == "seg" {
(
3,
Endpoint::Segment {
rendition: at(2)?.to_string(),
sequence: file.parse().ok()?,
},
)
} else if at(2)? == "part" {
(
4,
Endpoint::Part {
rendition: at(3)?.to_string(),
sequence: at(1)?.parse().ok()?,
index: file.parse().ok()?,
},
)
} else {
return None;
}
}
};
let split = count.checked_sub(trailing)?;
if split == 0 {
return None;
}
Some(Self {
broadcast: parts[..split].join("/"),
endpoint,
})
}
}
pub fn router(server: Server) -> Router {
Router::new().route("/{*path}", get(dispatch)).with_state(server)
}
async fn dispatch(State(server): State<Server>, Path(path): Path<String>, RawQuery(query): RawQuery) -> Response {
let Some(route) = Route::parse(&path) else {
return not_found();
};
let broadcast = route.broadcast;
match route.endpoint {
Endpoint::Master => master(&server, &broadcast).await,
Endpoint::Media { rendition } => media(&server, &broadcast, &rendition, query.as_deref()).await,
Endpoint::Init { rendition } => init(&server, &broadcast, &rendition).await,
Endpoint::Segment { rendition, sequence } => segment(&server, &broadcast, &rendition, sequence).await,
Endpoint::Part {
rendition,
sequence,
index,
} => part(&server, &broadcast, &rendition, sequence, index).await,
}
}
async fn master(server: &Server, broadcast: &str) -> Response {
let Some(handle) = server.handle(broadcast).await else {
return not_found();
};
handle.wait_ready(READY_TIMEOUT).await;
let snapshot = handle.snapshot();
if snapshot.is_empty() {
return not_found();
}
m3u8(snapshot.master_playlist())
}
async fn media(server: &Server, broadcast: &str, rendition: &str, query: Option<&str>) -> Response {
let msn = query_param(query, "_HLS_msn").and_then(|v| v.parse::<u64>().ok());
let part = query_param(query, "_HLS_part").and_then(|v| v.parse::<usize>().ok());
if msn.is_none() && part.is_some() {
return bad_request();
}
let Some(store) = store(server, broadcast, rendition).await else {
return not_found();
};
if let Some(msn) = msn {
let version = store.version();
if !version.finished && msn > version.last_sequence + 2 {
return bad_request();
}
block_until(&store, msn, part.unwrap_or(0)).await;
}
let snapshot = store.snapshot();
if !snapshot.init_ready || snapshot.segments.is_empty() {
return not_found();
}
m3u8(crate::export::render_media(&snapshot))
}
async fn init(server: &Server, broadcast: &str, rendition: &str) -> Response {
let Some(store) = store(server, broadcast, rendition).await else {
return not_found();
};
match store.init() {
Some(bytes) => media_bytes(bytes),
None => not_found(),
}
}
async fn segment(server: &Server, broadcast: &str, rendition: &str, sequence: u64) -> Response {
let Some(store) = store(server, broadcast, rendition).await else {
return not_found();
};
match store.segment(sequence) {
Some(bytes) => media_bytes(bytes),
None => not_found(),
}
}
async fn part(server: &Server, broadcast: &str, rendition: &str, sequence: u64, index: usize) -> Response {
let Some(store) = store(server, broadcast, rendition).await else {
return not_found();
};
let version = store.version();
if !version.finished && sequence > version.last_sequence + 1 {
return not_found();
}
block_until(&store, sequence, index).await;
match store.part(sequence, index) {
Some(bytes) => media_bytes(bytes),
None => not_found(),
}
}
async fn store(server: &Server, broadcast: &str, rendition: &str) -> Option<Arc<SegmentStore>> {
let handle = server.handle(broadcast).await?;
handle.wait_ready(READY_TIMEOUT).await;
handle.rendition(rendition).map(|r| r.store.clone())
}
async fn block_until(store: &SegmentStore, msn: u64, part: usize) {
if store.satisfies(msn, part) {
return;
}
let mut rx = store.subscribe();
let _ = tokio::time::timeout(BLOCK_TIMEOUT, async {
loop {
if store.satisfies(msn, part) {
break;
}
if rx.changed().await.is_err() {
break;
}
}
})
.await;
}
fn query_param<'a>(query: Option<&'a str>, key: &str) -> Option<&'a str> {
query?.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
(k == key).then_some(v)
})
}
fn m3u8(body: String) -> Response {
(
[(header::CONTENT_TYPE, M3U8), (header::CACHE_CONTROL, PLAYLIST_CACHE)],
body,
)
.into_response()
}
fn media_bytes(body: Bytes) -> Response {
(
[(header::CONTENT_TYPE, MP4), (header::CACHE_CONTROL, MEDIA_CACHE)],
body,
)
.into_response()
}
fn not_found() -> Response {
StatusCode::NOT_FOUND.into_response()
}
fn bad_request() -> Response {
StatusCode::BAD_REQUEST.into_response()
}
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt as _;
use super::*;
#[tokio::test]
async fn wildcard_capture_has_no_leading_slash() {
async fn echo(Path(path): Path<String>) -> String {
path
}
let response = Router::new()
.route("/{*path}", get(echo))
.oneshot(
Request::builder()
.uri("/live/video/media.m3u8")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(&body[..], b"live/video/media.m3u8");
}
fn parse(path: &str) -> (String, Endpoint) {
let route = Route::parse(path).expect("route should parse");
(route.broadcast, route.endpoint)
}
#[test]
fn parses_each_endpoint() {
assert!(matches!(parse("live/master.m3u8"), (b, Endpoint::Master) if b == "live"));
assert!(matches!(parse("live/video/media.m3u8"), (_, Endpoint::Media { rendition }) if rendition == "video"));
assert!(matches!(parse("live/video/init.mp4"), (_, Endpoint::Init { rendition }) if rendition == "video"));
assert!(
matches!(parse("live/video/seg/7.m4s"), (_, Endpoint::Segment { rendition, sequence }) if rendition == "video" && sequence == 7)
);
assert!(
matches!(parse("live/video/part/7/2.m4s"), (_, Endpoint::Part { rendition, sequence, index }) if rendition == "video" && sequence == 7 && index == 2)
);
}
#[test]
fn parses_hierarchical_broadcast_name() {
assert_eq!(parse("room/user/master.m3u8").0, "room/user");
assert_eq!(parse("a/b/c/video/media.m3u8").0, "a/b/c");
assert_eq!(parse("room/user/video/seg/3.m4s").0, "room/user");
assert_eq!(parse("room/user/video/part/3/1.m4s").0, "room/user");
}
#[test]
fn rejects_unknown_or_incomplete_paths() {
assert!(Route::parse("master.m3u8").is_none());
assert!(Route::parse("video/media.m3u8").is_none());
assert!(Route::parse("live/video/index.html").is_none());
assert!(Route::parse("live/video/blah/7.m4s").is_none());
assert!(Route::parse("live/video/seg/abc.m4s").is_none());
assert!(Route::parse("live/video/part/x/1.m4s").is_none());
}
#[test]
fn finds_query_params() {
assert_eq!(query_param(Some("_HLS_msn=4&_HLS_part=1"), "_HLS_msn"), Some("4"));
assert_eq!(query_param(Some("_HLS_msn=4&_HLS_part=1"), "_HLS_part"), Some("1"));
assert_eq!(query_param(Some("_HLS_msn=4"), "_HLS_part"), None);
assert_eq!(query_param(None, "_HLS_msn"), None);
}
}