mod process; mod session; use std::{collections::HashMap, path::PathBuf, sync::Arc}; use axum::{ Json, body::Body, extract::{Path, Query, State}, http::{StatusCode, header}, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; use tempfile::TempDir; use tokio::{fs, sync::Mutex}; use crate::{ app::AppState, error::AppError, media::{path, playback}, }; use self::session::DashSession; pub struct DashManager { _root: TempDir, root_path: PathBuf, sessions: Mutex>>, } #[derive(Serialize)] pub struct StartResponse { session_id: String, manifest_url: String, duration_seconds: Option, start_seconds: f64, } #[derive(Deserialize)] pub struct StartQuery { path: String, #[serde(default)] start: f64, } impl DashManager { pub fn new() -> anyhow::Result { let root = tempfile::Builder::new() .prefix("lan-play-dash-") .tempdir()?; Ok(Self { root_path: root.path().to_owned(), _root: root, sessions: Mutex::new(HashMap::new()), }) } } pub async fn start( State(state): State>, Query(query): Query, ) -> Result, AppError> { if !query.start.is_finite() || query.start < 0.0 { return Err(AppError::bad_request("invalid playback start position")); } let media = path::resolve(&state.config, &query.path)?; if !media.is_file() { return Err(AppError::bad_request("path is not a media file")); } let plan = playback::plan(&state.config, &media).await?; if plan .duration_seconds .is_some_and(|duration| query.start >= duration) { return Err(AppError::bad_request( "playback start is beyond the file duration", )); } let duration_seconds = plan.duration_seconds; let session = DashSession::start( &state.config.ffmpeg, &state.dash.root_path, &media, &plan, query.start, ) .await?; let session_id = session.id().to_owned(); state .dash .sessions .lock() .await .insert(session_id.clone(), Arc::new(session)); Ok(Json(StartResponse { manifest_url: format!("/api/dash/{session_id}/manifest.mpd"), session_id, duration_seconds, start_seconds: query.start, })) } pub async fn asset( State(state): State>, Path((session_id, asset)): Path<(String, String)>, ) -> Result { if !session::valid_asset_name(&asset) { return Err(AppError::bad_request("invalid DASH asset name")); } let session = state .dash .sessions .lock() .await .get(&session_id) .cloned() .ok_or_else(|| AppError::not_found("DASH session not found"))?; let file = session.asset_path(&asset); let bytes = fs::read(file) .await .map_err(|_| AppError::not_found("DASH asset not ready"))?; let content_type = asset_content_type(&asset); Ok(( StatusCode::OK, [ (header::CONTENT_TYPE, content_type), (header::CACHE_CONTROL, "no-store"), ], Body::from(bytes), ) .into_response()) } fn asset_content_type(asset: &str) -> &'static str { if asset.ends_with(".mpd") { "application/dash+xml" } else if dash_stream_index(asset).is_some_and(|stream| stream > 0) { "audio/iso.segment" } else if asset.ends_with(".m4s") || asset.ends_with(".mp4") { "video/iso.segment" } else { "application/octet-stream" } } fn dash_stream_index(asset: &str) -> Option { let start = asset.find("stream")? + "stream".len(); let digits: String = asset[start..] .chars() .take_while(|character| character.is_ascii_digit()) .collect(); digits.parse().ok() } pub async fn stop( State(state): State>, Path(session_id): Path, ) -> Result { let session = state .dash .sessions .lock() .await .remove(&session_id) .ok_or_else(|| AppError::not_found("DASH session not found"))?; session.stop().await; Ok(StatusCode::NO_CONTENT) } #[cfg(test)] mod tests { use super::asset_content_type; #[test] fn uses_media_specific_content_types() { assert_eq!(asset_content_type("manifest.mpd"), "application/dash+xml"); assert_eq!(asset_content_type("init-stream0.m4s"), "video/iso.segment"); assert_eq!( asset_content_type("chunk-stream0-00001.m4s"), "video/iso.segment" ); assert_eq!(asset_content_type("init-stream1.m4s"), "audio/iso.segment"); assert_eq!( asset_content_type("chunk-stream1-00001.m4s"), "audio/iso.segment" ); assert_eq!(asset_content_type("init-stream2.m4s"), "audio/iso.segment"); assert_eq!( asset_content_type("chunk-stream7-00001.m4s"), "audio/iso.segment" ); } }