use std::sync::Arc;
use axum::Router;
use axum::extract::{Query, State};
use axum::http::{StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use ll_hls_runtime::server::{
BlockingQuery, DEFAULT_TRACK_ID, LlHlsBody, LlHlsRequest, master_playlist_m3u8,
};
use serde::Deserialize;
use crate::http::{self, BLOCKING_RELOAD_TIMEOUT};
use crate::origin::resource::{BlockingRequestGuard, cors_preflight};
use crate::output::{Output, OutputKind};
use crate::route::RouteHandle;
const MEDIA_PLAYLIST_CONTENT_TYPE: &str = "application/vnd.apple.mpegurl";
pub const DEFAULT_PLAYLIST_NAME: &str = "media.m3u8";
pub struct LlHlsOutput {
playlist_name: String,
}
impl Default for LlHlsOutput {
fn default() -> Self {
LlHlsOutput::new(DEFAULT_PLAYLIST_NAME)
}
}
impl LlHlsOutput {
pub fn new(playlist_name: impl Into<String>) -> Self {
LlHlsOutput {
playlist_name: playlist_name.into(),
}
}
}
#[derive(Clone)]
pub(crate) struct LlHlsState {
route: Arc<RouteHandle>,
playlist_name: String,
}
impl Output for LlHlsOutput {
fn kind(&self) -> OutputKind {
OutputKind::LlHls
}
fn manifest_routes(&self, route: Arc<RouteHandle>) -> Router {
let state = LlHlsState {
route,
playlist_name: self.playlist_name.clone(),
};
Router::new()
.route("/master.m3u8", get(master_playlist).options(cors_preflight))
.route(
&format!("/{}", self.playlist_name),
get(media_playlist).options(cors_preflight),
)
.with_state(state)
}
}
pub(crate) async fn master_playlist(State(state): State<LlHlsState>) -> Response {
(
[(header::CONTENT_TYPE, MEDIA_PLAYLIST_CONTENT_TYPE)],
master_playlist_m3u8(&state.playlist_name),
)
.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>,
}
impl From<BlockingReloadQuery> for BlockingQuery {
fn from(q: BlockingReloadQuery) -> Self {
BlockingQuery {
hls_msn: q.hls_msn,
hls_part: q.hls_part,
}
}
}
pub(crate) async fn media_playlist(
State(state): State<LlHlsState>,
Query(q): Query<BlockingReloadQuery>,
) -> Response {
let serving = match http::resolve_route_program(&state.route) {
Ok(serving) => serving,
Err(resp) => return *resp,
};
let trunk = serving.trunk();
let ll_hls = serving.ll_hls();
let request = LlHlsRequest::Playlist {
track_id: DEFAULT_TRACK_ID,
query: q.into(),
};
let resp = http::resolve_blocking(
&trunk,
ll_hls.as_ref(),
request,
BLOCKING_RELOAD_TIMEOUT,
BlockingRequestGuard::new,
)
.await;
http::into_response(resp, StatusCode::NOT_FOUND, |body| match body {
LlHlsBody::Playlist(m) => {
([(header::CONTENT_TYPE, MEDIA_PLAYLIST_CONTENT_TYPE)], m).into_response()
}
LlHlsBody::Resource(_) => StatusCode::NOT_FOUND.into_response(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::route::RouteHandle;
use std::time::Duration;
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_string(resp: Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
fn state(route: Arc<RouteHandle>) -> LlHlsState {
LlHlsState {
route,
playlist_name: DEFAULT_PLAYLIST_NAME.to_string(),
}
}
#[tokio::test]
async fn master_playlist_ok() {
let route = make_route();
let resp = master_playlist(State(state(route))).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_points_at_configured_playlist_name() {
let route = make_route();
let resp = master_playlist(State(LlHlsState {
route,
playlist_name: "index.m3u8".to_string(),
}))
.await;
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(body.contains("index.m3u8"), "body: {body}");
assert!(!body.contains("media.m3u8"), "body: {body}");
}
#[tokio::test]
async fn media_playlist_no_query_renders_now() {
let route = make_route();
let resp = media_playlist(State(state(route)), 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_already_satisfied_blocking_request_resolves_immediately() {
let route = make_route();
let resp = media_playlist(
State(state(route)),
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 route = make_route();
let resp = media_playlist(
State(state(route)),
Query(BlockingReloadQuery {
hls_msn: Some(2),
hls_part: Some(1),
}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn media_playlist_msn_only_waits_for_closed_segment_not_just_open_parts() {
let route = make_route();
let route_for_task = route.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(80)).await;
route_for_task.add_segment(crate::route::SPTS_PROGRAM_ID, seg(2)); });
let started = std::time::Instant::now();
let resp = media_playlist(
State(state(route)),
Query(BlockingReloadQuery {
hls_msn: Some(2),
hls_part: None,
}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
assert!(
started.elapsed() >= Duration::from_millis(70),
"must have waited for segment 2 to close, not returned as soon as \
it had live parts: elapsed {:?}",
started.elapsed()
);
let body = body_string(resp).await;
assert!(
body.contains("seg-1-2.m4s"),
"resolved playlist must show segment 2 as a closed, fetchable segment: {body}"
);
}
#[tokio::test]
async fn media_playlist_far_future_msn_rejected_400_fast() {
let route = make_route();
let started = std::time::Instant::now();
let resp = media_playlist(
State(state(route)),
Query(BlockingReloadQuery {
hls_msn: Some(1002),
hls_part: None,
}),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
assert!(
started.elapsed() < Duration::from_secs(1),
"must reject promptly, not block out the 5s timeout: {:?}",
started.elapsed()
);
}
#[tokio::test]
async fn media_playlist_msn_within_bound_still_blocks_normally() {
let route = make_route(); let route_for_task = route.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
route_for_task.add_part(crate::route::SPTS_PROGRAM_ID, part(2, 2));
});
let resp = media_playlist(
State(state(route)),
Query(BlockingReloadQuery {
hls_msn: Some(2),
hls_part: Some(2),
}),
)
.await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn media_playlist_part_without_msn_rejected_400() {
let route = make_route();
let resp = media_playlist(
State(state(route)),
Query(BlockingReloadQuery {
hls_msn: None,
hls_part: Some(0),
}),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn media_playlist_not_yet_announced_is_503_not_404() {
let route = Arc::new(RouteHandle::new(4.0, 500, 4));
let resp = media_playlist(State(state(route)), Query(BlockingReloadQuery::default())).await;
assert_eq!(
resp.status(),
StatusCode::SERVICE_UNAVAILABLE,
"a route with no program announced yet must be 503 (not ready), not 404 (gone)"
);
}
#[tokio::test]
async fn options_preflight_returns_no_content() {
let route = make_route();
let router = LlHlsOutput::default().manifest_routes(route);
let req = axum::http::Request::builder()
.method("OPTIONS")
.uri("/media.m3u8")
.body(axum::body::Body::empty())
.unwrap();
let resp = tower::ServiceExt::oneshot(router, req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
}