Implement audio range requests
This commit enables serving audio files via HTTP Range requests. This is crucial for allowing HTML5 audio players to seek to specific positions within an audio file without re-downloading the entire file. The changes include: - Modifying `handle_audio` in `server/src/routes/web.rs` to parse `Range` headers. - Implementing `serve_range` to handle partial content responses. - Adding a `parse_range` helper function. - Updating tests to verify range request functionality. - Adding `ACCEPT_RANGES: bytes` header to indicate support for range requests. - Storing recording duration in a sidecar file for faster UI rendering. - Enhancing the HTML template to support a custom audio player with seeking.
This commit is contained in:
@@ -26,9 +26,7 @@ use crate::auth::AuthenticatedUser;
|
||||
use crate::config::Config;
|
||||
use crate::error::AppError;
|
||||
use crate::magic_link::{MagicLinkStore, PendingMagicLink};
|
||||
use crate::web_session::{
|
||||
SessionStore, WebSession, build_session_cookie, generate_token,
|
||||
};
|
||||
use crate::web_session::{SessionStore, WebSession, build_session_cookie, generate_token};
|
||||
|
||||
/// Magic-link tokens only need to survive click → browser-launch → first
|
||||
/// request. 60s gives slow systems headroom without lengthening the attack
|
||||
@@ -58,7 +56,9 @@ pub async fn handle_create(
|
||||
body: Option<Json<MagicLinkRequest>>,
|
||||
) -> Result<Json<MagicLinkResponse>, AppError> {
|
||||
let req = body.map(|Json(r)| r).unwrap_or_default();
|
||||
let return_to = req.return_to.unwrap_or_else(|| DEFAULT_RETURN_TO.to_owned());
|
||||
let return_to = req
|
||||
.return_to
|
||||
.unwrap_or_else(|| DEFAULT_RETURN_TO.to_owned());
|
||||
|
||||
// Open-redirect guard: only accept paths under our own /web/ tree.
|
||||
// Reject schemes, hosts, protocol-relative URLs, and traversal.
|
||||
|
||||
+268
-12
@@ -1,16 +1,29 @@
|
||||
use std::path::Path;
|
||||
use std::io::SeekFrom;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Path as AxumPath, State};
|
||||
use axum::http::header;
|
||||
use axum::http::{HeaderMap, StatusCode, header};
|
||||
use axum::response::Response;
|
||||
use doctate_common::timestamp::filename_stem_to_recorded_at;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use tokio::task::JoinSet;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::AppError;
|
||||
use crate::transcribe::ffmpeg;
|
||||
|
||||
pub(crate) struct RecordingView {
|
||||
pub(crate) filename: String,
|
||||
/// RFC3339 UTC timestamp derived from the filename stem. Empty only
|
||||
/// on malformed filenames, which should not occur in production.
|
||||
pub(crate) recorded_at_iso: String,
|
||||
/// Container duration in whole seconds, read from the `<stem>.duration.txt`
|
||||
/// sidecar. `None` means neither the worker nor the lazy-backfill path
|
||||
/// produced one — the UI then falls back to browser `preload="metadata"`.
|
||||
pub(crate) duration_seconds: Option<u32>,
|
||||
pub(crate) transcript: Option<String>,
|
||||
/// True if the file has been renamed to `<ts>.m4a.failed` by the worker
|
||||
/// after a non-recoverable ffmpeg or whisper error.
|
||||
@@ -20,6 +33,7 @@ pub(crate) struct RecordingView {
|
||||
pub async fn handle_audio(
|
||||
State(config): State<Arc<Config>>,
|
||||
AxumPath((user, case_id, filename)): AxumPath<(String, String, String)>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, AppError> {
|
||||
validate_user_slug(&user)?;
|
||||
uuid::Uuid::parse_str(&case_id)
|
||||
@@ -31,18 +45,94 @@ pub async fn handle_audio(
|
||||
return Err(AppError::NotFound("Audio file not found".into()));
|
||||
}
|
||||
let file_path = case_path.join(&filename);
|
||||
if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
|
||||
return Err(AppError::NotFound("Audio file not found".into()));
|
||||
let metadata = match tokio::fs::metadata(&file_path).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => return Err(AppError::NotFound("Audio file not found".into())),
|
||||
};
|
||||
let file_size = metadata.len();
|
||||
|
||||
// Range-conditional path — Chrome and friends send `Range: bytes=X-Y` after
|
||||
// `audio.currentTime = …`, and if we respond with the full file they treat
|
||||
// the seek as failed and reset currentTime to 0.
|
||||
if let Some(range_str) = headers.get(header::RANGE).and_then(|v| v.to_str().ok()) {
|
||||
return match parse_range(range_str, file_size) {
|
||||
Some((start, end)) => serve_range(&file_path, start, end, file_size).await,
|
||||
None => Response::builder()
|
||||
.status(StatusCode::RANGE_NOT_SATISFIABLE)
|
||||
.header(header::CONTENT_RANGE, format!("bytes */{file_size}"))
|
||||
.body(Body::empty())
|
||||
.map_err(|e| AppError::Internal(e.to_string())),
|
||||
};
|
||||
}
|
||||
|
||||
let bytes = tokio::fs::read(&file_path).await?;
|
||||
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, "audio/mp4")
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::CONTENT_LENGTH, file_size)
|
||||
.body(Body::from(bytes))
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
async fn serve_range(
|
||||
file_path: &Path,
|
||||
start: u64,
|
||||
end: u64,
|
||||
file_size: u64,
|
||||
) -> Result<Response, AppError> {
|
||||
let length = end - start + 1;
|
||||
let mut file = tokio::fs::File::open(file_path).await?;
|
||||
file.seek(SeekFrom::Start(start)).await?;
|
||||
let mut buf = vec![0u8; length as usize];
|
||||
file.read_exact(&mut buf).await?;
|
||||
Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(header::CONTENT_TYPE, "audio/mp4")
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
.header(header::CONTENT_LENGTH, length)
|
||||
.header(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {start}-{end}/{file_size}"),
|
||||
)
|
||||
.body(Body::from(buf))
|
||||
.map_err(|e| AppError::Internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Parse a single-range `Range: bytes=...` header against the given file size.
|
||||
/// Returns `(start, end_inclusive)` or `None` for unsatisfiable / unsupported
|
||||
/// forms. Multi-range requests take the first range only — Multipart responses
|
||||
/// aren't worth the complexity for dictation audio.
|
||||
fn parse_range(header: &str, file_size: u64) -> Option<(u64, u64)> {
|
||||
if file_size == 0 {
|
||||
return None;
|
||||
}
|
||||
let spec = header.strip_prefix("bytes=")?;
|
||||
let first = spec.split(',').next()?.trim();
|
||||
let (start_str, end_str) = first.split_once('-')?;
|
||||
if start_str.is_empty() {
|
||||
// "-N" → last N bytes
|
||||
let suffix: u64 = end_str.parse().ok()?;
|
||||
if suffix == 0 {
|
||||
return None;
|
||||
}
|
||||
let start = file_size.saturating_sub(suffix);
|
||||
return Some((start, file_size - 1));
|
||||
}
|
||||
let start: u64 = start_str.parse().ok()?;
|
||||
if start >= file_size {
|
||||
return None;
|
||||
}
|
||||
let end: u64 = if end_str.is_empty() {
|
||||
file_size - 1
|
||||
} else {
|
||||
end_str.parse::<u64>().ok()?.min(file_size - 1)
|
||||
};
|
||||
if start > end {
|
||||
return None;
|
||||
}
|
||||
Some((start, end))
|
||||
}
|
||||
|
||||
fn validate_user_slug(user: &str) -> Result<(), AppError> {
|
||||
if user.is_empty() || user.contains('/') || user.contains('\\') || user.contains("..") {
|
||||
return Err(AppError::BadRequest("Invalid user slug".into()));
|
||||
@@ -58,13 +148,33 @@ fn validate_filename(filename: &str) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract the timestamp stem (`YYYY-MM-DDTHH-MM-SSZ`) from a recording
|
||||
/// filename of the form `<stem>.m4a[.failed]`.
|
||||
fn stem_of(filename: &str) -> &str {
|
||||
filename
|
||||
.trim_end_matches(".failed")
|
||||
.trim_end_matches(".m4a")
|
||||
}
|
||||
|
||||
/// One entry in progress between the directory enumeration and the optional
|
||||
/// parallel ffprobe backfill. Holds the resolved paths so the spawned task
|
||||
/// doesn't have to know about the case_dir.
|
||||
struct RawRecording {
|
||||
filename: String,
|
||||
failed: bool,
|
||||
transcript: Option<String>,
|
||||
duration_seconds: Option<u32>,
|
||||
audio_path: PathBuf,
|
||||
sidecar_path: PathBuf,
|
||||
}
|
||||
|
||||
pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
|
||||
let mut recordings = Vec::new();
|
||||
let mut entries = match tokio::fs::read_dir(case_dir).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return recordings,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut raws: Vec<RawRecording> = Vec::new();
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let filename = match entry.file_name().into_string() {
|
||||
Ok(s) => s,
|
||||
@@ -75,18 +185,164 @@ pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
|
||||
if !is_audio {
|
||||
continue;
|
||||
}
|
||||
// Transcript naming uses the original `<ts>.m4a` stem in both cases
|
||||
// (failed recordings never produced one, but the lookup still works).
|
||||
let stem_path = case_dir.join(filename.trim_end_matches(".failed"));
|
||||
// Transcript and duration sidecar both hang off the non-failed stem path;
|
||||
// a `.failed` file never produced a transcript but the lookup still works.
|
||||
let stem_file = filename.trim_end_matches(".failed");
|
||||
let stem_path = case_dir.join(stem_file);
|
||||
let transcript_path = stem_path.with_extension("transcript.txt");
|
||||
let transcript = tokio::fs::read_to_string(&transcript_path).await.ok();
|
||||
recordings.push(RecordingView {
|
||||
|
||||
let sidecar_path = stem_path.with_extension("duration.txt");
|
||||
let duration_seconds = tokio::fs::read_to_string(&sidecar_path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<u32>().ok());
|
||||
|
||||
let audio_path = case_dir.join(&filename);
|
||||
raws.push(RawRecording {
|
||||
filename,
|
||||
transcript,
|
||||
failed,
|
||||
transcript,
|
||||
duration_seconds,
|
||||
audio_path,
|
||||
sidecar_path,
|
||||
});
|
||||
}
|
||||
|
||||
// Lazy backfill: spawn ffprobe in parallel for recordings without a sidecar.
|
||||
// `.failed` entries are skipped — ffprobe on a corrupt file is unlikely to
|
||||
// succeed and the UI doesn't need a duration there.
|
||||
let mut js: JoinSet<Option<(usize, u32)>> = JoinSet::new();
|
||||
for (idx, r) in raws.iter().enumerate() {
|
||||
if r.duration_seconds.is_some() || r.failed {
|
||||
continue;
|
||||
}
|
||||
let audio_path = r.audio_path.clone();
|
||||
let sidecar_path = r.sidecar_path.clone();
|
||||
js.spawn(async move {
|
||||
let secs = ffmpeg::probe_duration_seconds(&audio_path).await.ok()?;
|
||||
if let Err(e) = tokio::fs::write(&sidecar_path, secs.to_string()).await {
|
||||
debug!(sidecar = %sidecar_path.display(), error = %e, "duration sidecar write failed");
|
||||
}
|
||||
Some((idx, secs))
|
||||
});
|
||||
}
|
||||
while let Some(res) = js.join_next().await {
|
||||
if let Ok(Some((idx, secs))) = res
|
||||
&& let Some(raw) = raws.get_mut(idx)
|
||||
{
|
||||
raw.duration_seconds = Some(secs);
|
||||
}
|
||||
}
|
||||
|
||||
let mut recordings: Vec<RecordingView> = raws
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let recorded_at_iso = filename_stem_to_recorded_at(stem_of(&r.filename));
|
||||
RecordingView {
|
||||
filename: r.filename,
|
||||
recorded_at_iso,
|
||||
duration_seconds: r.duration_seconds,
|
||||
transcript: r.transcript,
|
||||
failed: r.failed,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
recordings.sort_by(|a, b| a.filename.cmp(&b.filename));
|
||||
recordings
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn scan_reads_duration_sidecar_and_derives_iso() {
|
||||
let dir = tempdir().unwrap();
|
||||
tokio::fs::write(dir.path().join("2026-04-19T10-00-00Z.m4a"), b"audio")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(dir.path().join("2026-04-19T10-00-00Z.duration.txt"), "42")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(
|
||||
dir.path().join("2026-04-19T10-00-00Z.transcript.txt"),
|
||||
"hallo",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let got = scan_recordings(dir.path()).await;
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].filename, "2026-04-19T10-00-00Z.m4a");
|
||||
assert_eq!(got[0].recorded_at_iso, "2026-04-19T10:00:00Z");
|
||||
assert_eq!(got[0].duration_seconds, Some(42));
|
||||
assert_eq!(got[0].transcript.as_deref(), Some("hallo"));
|
||||
assert!(!got[0].failed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scan_handles_failed_suffix() {
|
||||
let dir = tempdir().unwrap();
|
||||
tokio::fs::write(dir.path().join("2026-04-19T11-00-00Z.m4a.failed"), b"x")
|
||||
.await
|
||||
.unwrap();
|
||||
let got = scan_recordings(dir.path()).await;
|
||||
assert_eq!(got.len(), 1);
|
||||
assert!(got[0].failed);
|
||||
assert_eq!(got[0].recorded_at_iso, "2026-04-19T11:00:00Z");
|
||||
assert!(got[0].duration_seconds.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_range_explicit_bounds() {
|
||||
assert_eq!(parse_range("bytes=0-99", 1000), Some((0, 99)));
|
||||
assert_eq!(parse_range("bytes=500-599", 1000), Some((500, 599)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_range_open_end_clamped_to_size() {
|
||||
assert_eq!(parse_range("bytes=100-", 1000), Some((100, 999)));
|
||||
assert_eq!(parse_range("bytes=0-99999", 1000), Some((0, 999)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_range_suffix_last_n_bytes() {
|
||||
assert_eq!(parse_range("bytes=-50", 1000), Some((950, 999)));
|
||||
assert_eq!(parse_range("bytes=-2000", 1000), Some((0, 999)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_range_rejects_out_of_bounds_start() {
|
||||
assert!(parse_range("bytes=1000-", 1000).is_none());
|
||||
assert!(parse_range("bytes=2000-3000", 1000).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_range_rejects_malformed() {
|
||||
assert!(parse_range("items=0-99", 1000).is_none());
|
||||
assert!(parse_range("bytes=abc-99", 1000).is_none());
|
||||
assert!(parse_range("bytes=-0", 1000).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scan_skips_corrupt_sidecar() {
|
||||
let dir = tempdir().unwrap();
|
||||
tokio::fs::write(dir.path().join("2026-04-19T12-00-00Z.m4a"), b"audio")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(
|
||||
dir.path().join("2026-04-19T12-00-00Z.duration.txt"),
|
||||
"not-a-number",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// No ffprobe available in unit-test context (or it fails on "audio"), so
|
||||
// duration_seconds remains None — exactly what we want to assert.
|
||||
let got = scan_recordings(dir.path()).await;
|
||||
assert_eq!(got.len(), 1);
|
||||
assert!(got[0].duration_seconds.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub enum FfmpegError {
|
||||
Spawn(std::io::Error),
|
||||
NonZeroExit { code: Option<i32>, stderr: String },
|
||||
TempFile(std::io::Error),
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FfmpegError {
|
||||
@@ -20,6 +21,7 @@ impl std::fmt::Display for FfmpegError {
|
||||
write!(f, "ffmpeg exited with {code:?}: {stderr}")
|
||||
}
|
||||
Self::TempFile(e) => write!(f, "failed to create temp file: {e}"),
|
||||
Self::Parse(raw) => write!(f, "failed to parse ffprobe output: {raw:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,3 +70,42 @@ pub async fn remux_faststart(input: &Path) -> Result<NamedTempFile, FfmpegError>
|
||||
debug!(?output_path, "ffmpeg remux completed");
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Probe the duration (in whole seconds, rounded) of an audio file via `ffprobe`.
|
||||
///
|
||||
/// Cheap — ffprobe only reads container headers, no decoding. On a faststart-remuxed
|
||||
/// file this is single-digit milliseconds. Uses the `format=duration` entry which
|
||||
/// is the canonical container-level duration for m4a/MP4.
|
||||
pub async fn probe_duration_seconds(path: &Path) -> Result<u32, FfmpegError> {
|
||||
let output = Command::new("ffprobe")
|
||||
.arg("-v")
|
||||
.arg("error")
|
||||
.arg("-show_entries")
|
||||
.arg("format=duration")
|
||||
.arg("-of")
|
||||
.arg("default=noprint_wrappers=1:nokey=1")
|
||||
.arg(path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.map_err(FfmpegError::Spawn)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(FfmpegError::NonZeroExit {
|
||||
code: output.status.code(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let raw = String::from_utf8_lossy(&output.stdout);
|
||||
let trimmed = raw.trim();
|
||||
let secs: f64 = trimmed
|
||||
.parse()
|
||||
.map_err(|_| FfmpegError::Parse(trimmed.to_string()))?;
|
||||
if !secs.is_finite() || secs < 0.0 {
|
||||
return Err(FfmpegError::Parse(trimmed.to_string()));
|
||||
}
|
||||
Ok(secs.round() as u32)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ pub async fn run(
|
||||
}
|
||||
};
|
||||
|
||||
// Persist the container duration as a sidecar so the UI can render it
|
||||
// without the browser having to HEAD every audio file. Non-fatal — the
|
||||
// recordings scan has a lazy-backfill path, and transcription is the
|
||||
// critical workload here.
|
||||
write_duration_sidecar(&audio_path, remuxed.path()).await;
|
||||
|
||||
let text = match whisper::transcribe(
|
||||
&client,
|
||||
&config.whisper_url,
|
||||
@@ -126,6 +132,23 @@ async fn mark_failed(audio_path: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the (remuxed) audio and persist the duration next to the original file
|
||||
/// as `<ts>.duration.txt`. Probes on the remuxed copy because its `moov` atom
|
||||
/// sits at the start — ffprobe returns without seeking to EOF.
|
||||
async fn write_duration_sidecar(audio_path: &Path, probe_path: &Path) {
|
||||
let secs = match ffmpeg::probe_duration_seconds(probe_path).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
warn!(audio = %audio_path.display(), error = %e, "ffprobe duration failed — UI will lazy-backfill");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let sidecar = audio_path.with_extension("duration.txt");
|
||||
if let Err(e) = tokio::fs::write(&sidecar, secs.to_string()).await {
|
||||
warn!(sidecar = %sidecar.display(), error = %e, "writing duration sidecar failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Regenerate `case_dir/oneliner.txt` from **all** non-empty transcripts in
|
||||
/// the case, joined chronologically. Called after every successful transcript
|
||||
/// write so later recordings can correct earlier ones (mirror of the
|
||||
|
||||
Reference in New Issue
Block a user