use std::sync::Arc;
use std::time::Duration;
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, PlaylistOutcome, master_playlist_m3u8,
};
use serde::Deserialize;
use crate::origin::resource::{BlockingRequestGuard, cors_preflight};
use crate::output::{Output, OutputKind};
use crate::store::MediaStore;
pub use ll_hls_runtime::server::media_playlist_m3u8;
const BLOCKING_RELOAD_TIMEOUT: Duration = Duration::from_secs(5);
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 {
store: Arc<MediaStore>,
playlist_name: String,
}
impl Output for LlHlsOutput {
fn kind(&self) -> OutputKind {
OutputKind::LlHls
}
fn manifest_routes(&self, store: Arc<MediaStore>) -> Router {
let state = LlHlsState {
store,
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,
}
}
}
async fn media_playlist_blocking(
store: &MediaStore,
track_id: u32,
query: BlockingQuery,
) -> Result<String, ()> {
match store.resolve_playlist(track_id, query) {
PlaylistOutcome::Ready(body) => return Ok(body),
PlaylistOutcome::BadRequest => return Err(()),
PlaylistOutcome::WouldBlock => {}
_ => return Err(()),
}
let _guard = BlockingRequestGuard::new();
let wait = async {
loop {
let listener = store.listen();
match store.resolve_playlist(track_id, query) {
PlaylistOutcome::Ready(body) => return Some(body),
PlaylistOutcome::BadRequest => return None,
PlaylistOutcome::WouldBlock => {}
_ => return None,
}
listener.await;
}
};
let resolved = tokio::time::timeout(BLOCKING_RELOAD_TIMEOUT, wait)
.await
.ok()
.flatten();
Ok(resolved.unwrap_or_else(|| media_playlist_m3u8(store, track_id)))
}
pub(crate) async fn media_playlist(
State(state): State<LlHlsState>,
Query(q): Query<BlockingReloadQuery>,
) -> Response {
match media_playlist_blocking(&state.store, DEFAULT_TRACK_ID, q.into()).await {
Ok(body) => ([(header::CONTENT_TYPE, MEDIA_PLAYLIST_CONTENT_TYPE)], body).into_response(),
Err(()) => StatusCode::BAD_REQUEST.into_response(),
}
}
#[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_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(store: Arc<MediaStore>) -> LlHlsState {
LlHlsState {
store,
playlist_name: DEFAULT_PLAYLIST_NAME.to_string(),
}
}
#[tokio::test]
async fn master_playlist_ok() {
let store = make_store();
let resp = master_playlist(State(state(store))).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 store = make_store();
let resp = master_playlist(State(LlHlsState {
store,
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 store = make_store();
let resp = media_playlist(State(state(store)), 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 store = make_store();
let resp = media_playlist(
State(state(store)),
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 store = make_store();
let resp = media_playlist(
State(state(store)),
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 store = make_store();
let store_for_task = store.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(80)).await;
store_for_task.add_segment(seg(2)); });
let started = std::time::Instant::now();
let resp = media_playlist(
State(state(store)),
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 store = make_store();
let started = std::time::Instant::now();
let resp = media_playlist(
State(state(store)),
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 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 = media_playlist(
State(state(store)),
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 store = make_store();
let resp = media_playlist(
State(state(store)),
Query(BlockingReloadQuery {
hls_msn: None,
hls_part: Some(0),
}),
)
.await;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn options_preflight_returns_no_content() {
let store = make_store();
let router = LlHlsOutput::default().manifest_routes(store);
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);
}
}