diff --git a/client-desktop/src/app.rs b/client-desktop/src/app.rs index 8cc75c7..dc90907 100644 --- a/client-desktop/src/app.rs +++ b/client-desktop/src/app.rs @@ -292,20 +292,16 @@ impl DoctateApp { // Fire-and-forget on the runtime. Blocking the egui UI thread on // an HTTP round-trip would freeze the window for ~100–500ms. self.runtime.spawn(async move { - let url = match crate::magic_link::build_magic_url( - &http, - &server_url, - &api_key, - &return_to, - ) - .await - { - Ok(u) => u, - Err(e) => { - warn!(error = %e, "magic-link request failed; falling back to plain URL"); - fallback_url - } - }; + let url = + match crate::magic_link::build_magic_url(&http, &server_url, &api_key, &return_to) + .await + { + Ok(u) => u, + Err(e) => { + warn!(error = %e, "magic-link request failed; falling back to plain URL"); + fallback_url + } + }; if let Err(e) = webbrowser::open(&url) { warn!(url = %url, error = %e, "open browser failed"); } diff --git a/client-desktop/src/magic_link.rs b/client-desktop/src/magic_link.rs index 4a33cbf..678113d 100644 --- a/client-desktop/src/magic_link.rs +++ b/client-desktop/src/magic_link.rs @@ -117,4 +117,3 @@ mod tests { assert!(!url.contains("//web/"), "double slash leaked: {url}"); } } - diff --git a/server/src/routes/magic.rs b/server/src/routes/magic.rs index 4f4eecb..069d4fe 100644 --- a/server/src/routes/magic.rs +++ b/server/src/routes/magic.rs @@ -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>, ) -> Result, 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. diff --git a/server/src/routes/web.rs b/server/src/routes/web.rs index a917ba3..5aed64b 100644 --- a/server/src/routes/web.rs +++ b/server/src/routes/web.rs @@ -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 `.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, pub(crate) transcript: Option, /// True if the file has been renamed to `.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>, AxumPath((user, case_id, filename)): AxumPath<(String, String, String)>, + headers: HeaderMap, ) -> Result { 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 { + 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::().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 `.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, + duration_seconds: Option, + audio_path: PathBuf, + sidecar_path: PathBuf, +} + pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec { - 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 = 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 { if !is_audio { continue; } - // Transcript naming uses the original `.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::().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> = 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 = 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()); + } +} diff --git a/server/src/transcribe/ffmpeg.rs b/server/src/transcribe/ffmpeg.rs index fe1d472..707846d 100644 --- a/server/src/transcribe/ffmpeg.rs +++ b/server/src/transcribe/ffmpeg.rs @@ -10,6 +10,7 @@ pub enum FfmpegError { Spawn(std::io::Error), NonZeroExit { code: Option, 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 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 { + 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) +} diff --git a/server/src/transcribe/worker.rs b/server/src/transcribe/worker.rs index 2c6717b..2fb343d 100644 --- a/server/src/transcribe/worker.rs +++ b/server/src/transcribe/worker.rs @@ -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 `.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 diff --git a/server/templates/case_recordings.html b/server/templates/case_recordings.html index a11df28..e6b1614 100644 --- a/server/templates/case_recordings.html +++ b/server/templates/case_recordings.html @@ -14,11 +14,50 @@ header form { margin: 0; } .recording { margin: 1em 0; padding: 0.7em; border: 1px solid #ddd; border-radius: 4px; } .recording.failed-row { background: #fff7f7; border-left: 3px solid #e24a4a; } .recording-head { display: flex; align-items: center; gap: 1em; flex-wrap: wrap; } -.recording-head span { font-family: monospace; font-size: 0.9em; min-width: 20em; } +.rec-time { font-family: monospace; font-size: 0.95em; min-width: 11em; color: #333; } +.filename { font-family: monospace; font-size: 0.8em; color: #999; } .transcript { margin: 0.6em 0 0; padding: 0.6em; background: #f6f6f6; border-radius: 4px; white-space: pre-wrap; font-family: serif; } .pending { color: #888; font-style: italic; margin-top: 0.5em; } .failed { color: #b00; font-weight: bold; margin-top: 0.5em; } +.player { display: inline-flex; align-items: center; gap: 0.5em; flex: 1; min-width: 20em; } +.player .play { + position: relative; + width: 2em; height: 2em; + border: 1px solid #bbb; border-radius: 50%; + background: #fff; cursor: pointer; padding: 0; + flex-shrink: 0; +} +.player .play:hover { background: #f0f0f0; } +/* Play triangle — pseudo-element via border-trick, font-independent. */ +.player .play::before { + content: ''; + position: absolute; + top: 50%; left: 50%; + width: 0; height: 0; + border-style: solid; + border-width: 7px 0 7px 10px; + border-color: transparent transparent transparent #333; + transform: translate(-35%, -50%); +} +/* Pause — two bars via ::before (reused) + ::after. */ +.player .play.is-pause::before { + width: 3px; height: 12px; + border: none; + background: #333; + transform: translate(-5px, -50%); +} +.player .play.is-pause::after { + content: ''; + position: absolute; + top: 50%; left: 50%; + width: 3px; height: 12px; + background: #333; + transform: translate(2px, -50%); +} +.player .seek { flex: 1; cursor: pointer; } +.player .time { font-family: monospace; font-size: 0.85em; color: #555; min-width: 5.5em; text-align: right; } + .header-right { display: flex; align-items: center; gap: 0.8em; } .admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; } html.admin-view-off .admin-only { display: none !important; } @@ -54,10 +93,13 @@ try { {% for rec in recordings %}
-{{ rec.filename }} - + +
+ + +0:00 / 0:00 +
+{% if is_admin %}{{ rec.filename }}{% endif %}
{% if rec.failed %}
Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.
@@ -82,12 +124,125 @@ try { diff --git a/server/tests/magic_link_test.rs b/server/tests/magic_link_test.rs index 747ebad..fe65f15 100644 --- a/server/tests/magic_link_test.rs +++ b/server/tests/magic_link_test.rs @@ -90,10 +90,7 @@ async fn create_without_api_key_returns_401() { let (state, _) = build_state(); let app = doctate_server::create_router_with_state(state); - let resp = app - .oneshot(create_request("{}", None)) - .await - .unwrap(); + let resp = app.oneshot(create_request("{}", None)).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } @@ -193,7 +190,10 @@ async fn consume_token_is_one_time_use() { let second = app.oneshot(consume_request(&token)).await.unwrap(); // Second use: token gone, redirect to login. assert_eq!(second.status(), StatusCode::SEE_OTHER); - assert_eq!(second.headers().get(header::LOCATION).unwrap(), "/web/login"); + assert_eq!( + second.headers().get(header::LOCATION).unwrap(), + "/web/login" + ); } #[tokio::test] @@ -249,4 +249,3 @@ async fn consume_expired_token_redirects_to_login() { // And the token must be removed from the store, even though it expired. assert!(store.read().await.get(&token).is_none()); } - diff --git a/server/tests/web_test.rs b/server/tests/web_test.rs index 87ee98a..fe0c935 100644 --- a/server/tests/web_test.rs +++ b/server/tests/web_test.rs @@ -106,6 +106,154 @@ async fn web_audio_invalid_case_id_rejected() { assert_eq!(response.status(), StatusCode::BAD_REQUEST); } +#[tokio::test] +async fn web_audio_serves_range_as_206() { + let config = test_config(); + let data_path = config.data_path.clone(); + + let case_id = "770e8400-e29b-41d4-a716-446655440001"; + let case_dir = data_path.join("dr_test").join(case_id); + std::fs::create_dir_all(&case_dir).unwrap(); + + let audio_bytes: Vec = (0u8..=200u8).collect(); + let filename = "2026-04-13T10-30-00Z.m4a"; + std::fs::write(case_dir.join(filename), &audio_bytes).unwrap(); + + let app = doctate_server::create_router(config); + let response = app + .oneshot( + Request::builder() + .uri(format!("/web/audio/dr_test/{case_id}/{filename}")) + .header("Range", "bytes=10-19") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!( + response + .headers() + .get("content-range") + .unwrap() + .to_str() + .unwrap(), + format!("bytes 10-19/{}", audio_bytes.len()) + ); + assert_eq!( + response + .headers() + .get("content-length") + .unwrap() + .to_str() + .unwrap(), + "10" + ); + assert_eq!( + response + .headers() + .get("accept-ranges") + .unwrap() + .to_str() + .unwrap(), + "bytes" + ); + + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(&bytes[..], &audio_bytes[10..20]); + + let _ = std::fs::remove_dir_all(&data_path); +} + +#[tokio::test] +async fn web_audio_invalid_range_returns_416() { + let config = test_config(); + let data_path = config.data_path.clone(); + + let case_id = "770e8400-e29b-41d4-a716-446655440002"; + let case_dir = data_path.join("dr_test").join(case_id); + std::fs::create_dir_all(&case_dir).unwrap(); + + let audio_bytes = b"short"; + let filename = "2026-04-13T10-30-00Z.m4a"; + std::fs::write(case_dir.join(filename), audio_bytes).unwrap(); + + let app = doctate_server::create_router(config); + let response = app + .oneshot( + Request::builder() + .uri(format!("/web/audio/dr_test/{case_id}/{filename}")) + .header("Range", "bytes=1000-2000") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE); + assert_eq!( + response + .headers() + .get("content-range") + .unwrap() + .to_str() + .unwrap(), + format!("bytes */{}", audio_bytes.len()) + ); + + let _ = std::fs::remove_dir_all(&data_path); +} + +#[tokio::test] +async fn web_audio_no_range_header_advertises_accept_ranges() { + let config = test_config(); + let data_path = config.data_path.clone(); + + let case_id = "770e8400-e29b-41d4-a716-446655440003"; + let case_dir = data_path.join("dr_test").join(case_id); + std::fs::create_dir_all(&case_dir).unwrap(); + + let audio_bytes = b"full file content"; + let filename = "2026-04-13T10-30-00Z.m4a"; + std::fs::write(case_dir.join(filename), audio_bytes).unwrap(); + + let app = doctate_server::create_router(config); + let response = app + .oneshot( + Request::builder() + .uri(format!("/web/audio/dr_test/{case_id}/{filename}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("accept-ranges") + .unwrap() + .to_str() + .unwrap(), + "bytes" + ); + assert_eq!( + response + .headers() + .get("content-length") + .unwrap() + .to_str() + .unwrap(), + audio_bytes.len().to_string() + ); + + let _ = std::fs::remove_dir_all(&data_path); +} + #[tokio::test] async fn web_audio_nonexistent_file_returns_404() { let config = test_config();