This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
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<HashMap<String, Arc<DashSession>>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct StartResponse {
|
||||
session_id: String,
|
||||
manifest_url: String,
|
||||
duration_seconds: Option<f64>,
|
||||
start_seconds: f64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct StartQuery {
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
start: f64,
|
||||
}
|
||||
|
||||
impl DashManager {
|
||||
pub fn new() -> anyhow::Result<Self> {
|
||||
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<Arc<AppState>>,
|
||||
Query(query): Query<StartQuery>,
|
||||
) -> Result<Json<StartResponse>, 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<Arc<AppState>>,
|
||||
Path((session_id, asset)): Path<(String, String)>,
|
||||
) -> Result<Response, AppError> {
|
||||
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<usize> {
|
||||
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<Arc<AppState>>,
|
||||
Path(session_id): Path<String>,
|
||||
) -> Result<StatusCode, AppError> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user