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 hls_runtime::server::{DEFAULT_TRACK_ID, HlsBody, HlsRequest, master_playlist_m3u8};
use crate::http::{self, BLOCKING_RELOAD_TIMEOUT};
use crate::origin::resource::{BlockingRequestGuard, cors_preflight};
use crate::output::llhls::BlockingReloadQuery;
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 TsHlsOutput {
playlist_name: String,
}
impl Default for TsHlsOutput {
fn default() -> Self {
TsHlsOutput::new(DEFAULT_PLAYLIST_NAME)
}
}
impl TsHlsOutput {
pub fn new(playlist_name: impl Into<String>) -> Self {
TsHlsOutput {
playlist_name: playlist_name.into(),
}
}
}
#[derive(Clone)]
pub(crate) struct TsHlsState {
route: Arc<RouteHandle>,
playlist_name: String,
}
impl Output for TsHlsOutput {
fn kind(&self) -> OutputKind {
OutputKind::TsHls
}
fn manifest_routes(&self, route: Arc<RouteHandle>) -> Router {
let state = TsHlsState {
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<TsHlsState>) -> Response {
(
[(header::CONTENT_TYPE, MEDIA_PLAYLIST_CONTENT_TYPE)],
master_playlist_m3u8(&state.playlist_name),
)
.into_response()
}
pub(crate) async fn media_playlist(
State(state): State<TsHlsState>,
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 = HlsRequest::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 {
HlsBody::Playlist(m) => {
([(header::CONTENT_TYPE, MEDIA_PLAYLIST_CONTENT_TYPE)], m).into_response()
}
HlsBody::Resource(_) => StatusCode::NOT_FOUND.into_response(),
_ => StatusCode::NOT_FOUND.into_response(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::route::RouteHandle;
use hls_runtime::server::Container;
use transmux::ll_hls::SegmentInfo;
fn seg(seq: u32, byte: u8) -> SegmentInfo {
SegmentInfo {
bytes: vec![byte; 8],
duration: 4.2,
segment_seq: seq,
part_count: 0,
}
}
fn make_route() -> Arc<RouteHandle> {
let route = Arc::new(RouteHandle::new(4.0, 500, 4).with_container(Container::MpegTs));
route.publish_new_program(crate::route::SPTS_PROGRAM_ID);
route.add_segment(crate::route::SPTS_PROGRAM_ID, seg(1, 0x21));
route.add_segment(crate::route::SPTS_PROGRAM_ID, seg(2, 0x22));
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>) -> TsHlsState {
TsHlsState {
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 media_playlist_serves_ts_segments_with_no_ext_x_map() {
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("#EXTINF:"), "body: {body}");
assert!(body.contains("seg-1-1.ts"), "body: {body}");
assert!(body.contains("seg-1-2.ts"), "body: {body}");
assert!(
!body.contains("#EXT-X-MAP"),
"a classic TS media playlist must never advertise an init segment: {body}"
);
assert!(
!body.contains(".m4s"),
"a classic TS route must never reference fMP4 segment filenames: {body}"
);
}
#[tokio::test]
async fn media_playlist_version_is_broadcast_hls_derived_not_hardcoded() {
let route = make_route();
let resp = media_playlist(State(state(route)), Query(BlockingReloadQuery::default())).await;
let body = body_string(resp).await;
let rendered_version: u8 = body
.lines()
.find_map(|l| l.strip_prefix("#EXT-X-VERSION:"))
.expect("a rendered media playlist always carries #EXT-X-VERSION here")
.parse()
.expect("#EXT-X-VERSION value must be a valid integer");
let equivalent = broadcast_hls::MediaPlaylist {
target_duration: 4,
media_sequence: 1,
segments: vec![
broadcast_hls::MediaSegment {
uri: "seg-1-1.ts".to_string(),
duration: 4.2,
..Default::default()
},
broadcast_hls::MediaSegment {
uri: "seg-1-2.ts".to_string(),
duration: 4.2,
..Default::default()
},
],
..Default::default()
};
let expected_version = equivalent
.computed_version()
.expect("floating-point EXTINF durations trigger a real version floor");
assert_eq!(
rendered_version, expected_version,
"the served playlist's #EXT-X-VERSION must equal broadcast-hls's own \
content-derived value for equivalent segment data, not a value multimux chose"
);
}
#[tokio::test]
async fn advertised_ts_segment_is_exactly_what_was_published() {
let route = make_route();
let router = crate::origin::resource::router(route.clone())
.merge(TsHlsOutput::default().manifest_routes(route));
let playlist_req = axum::http::Request::builder()
.method("GET")
.uri("/media.m3u8")
.body(axum::body::Body::empty())
.unwrap();
let playlist_resp = tower::ServiceExt::oneshot(router.clone(), playlist_req)
.await
.unwrap();
assert_eq!(playlist_resp.status(), StatusCode::OK);
let playlist = body_string(playlist_resp).await;
let uris: Vec<&str> = playlist
.lines()
.filter(|l| !l.starts_with('#') && l.ends_with(".ts"))
.collect();
assert_eq!(
uris.len(),
2,
"make_route() published exactly 2 segments: {playlist}"
);
for uri in uris {
let req = axum::http::Request::builder()
.method("GET")
.uri(format!("/{uri}"))
.body(axum::body::Body::empty())
.unwrap();
let resp = tower::ServiceExt::oneshot(router.clone(), req)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"requesting advertised uri {uri:?}"
);
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let expected_byte = if uri.contains("-1.ts") {
0x21u8
} else {
0x22u8
};
assert_eq!(
bytes.to_vec(),
vec![expected_byte; 8],
"served bytes for advertised uri {uri:?} must match what was published"
);
}
}
#[tokio::test]
async fn options_preflight_returns_no_content() {
let route = make_route();
let router = TsHlsOutput::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);
}
#[tokio::test]
async fn media_playlist_not_yet_announced_is_503_not_404() {
let route = Arc::new(RouteHandle::new(4.0, 500, 4).with_container(Container::MpegTs));
let resp = media_playlist(State(state(route)), Query(BlockingReloadQuery::default())).await;
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
}
}