196 lines
5.1 KiB
Rust
196 lines
5.1 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|