Files
lan-play/src/media/dash/mod.rs
T
Ultradesu eb0d4fb1c5
Build and Publish / Build and Publish Docker Image (push) Successful in 10m8s
Init
2026-08-05 12:32:52 +01:00

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"
);
}
}