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
|
||||
|
||||
@@ -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 %}
|
||||
<div class="recording{% if rec.failed %} failed-row{% endif %}">
|
||||
<div class="recording-head">
|
||||
<span>{{ rec.filename }}</span>
|
||||
<audio controls preload="none">
|
||||
<source src="/web/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}" type="audio/mp4">
|
||||
</audio>
|
||||
<time class="rec-time" datetime="{{ rec.recorded_at_iso }}">{{ rec.recorded_at_iso }}</time>
|
||||
<div class="player" data-src="/web/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}"{% match rec.duration_seconds %}{% when Some with (d) %} data-duration="{{ d }}"{% when None %}{% endmatch %}>
|
||||
<button type="button" class="play" aria-label="Abspielen"></button>
|
||||
<input type="range" class="seek" min="0" max="1000" value="0" step="1" aria-label="Position">
|
||||
<span class="time">0:00 / 0:00</span>
|
||||
</div>
|
||||
{% if is_admin %}<span class="filename admin-only">{{ rec.filename }}</span>{% endif %}
|
||||
</div>
|
||||
{% if rec.failed %}
|
||||
<div class="failed">Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.</div>
|
||||
@@ -82,12 +124,125 @@ try {
|
||||
<script>
|
||||
(() => {
|
||||
const cb = document.getElementById('admin-view-toggle');
|
||||
if (!cb) return;
|
||||
cb.checked = !document.documentElement.classList.contains('admin-view-off');
|
||||
cb.addEventListener('change', () => {
|
||||
const on = cb.checked;
|
||||
try { localStorage.setItem('admin-view', on ? 'on' : 'off'); } catch (_) {}
|
||||
document.documentElement.classList.toggle('admin-view-off', !on);
|
||||
if (cb) {
|
||||
cb.checked = !document.documentElement.classList.contains('admin-view-off');
|
||||
cb.addEventListener('change', () => {
|
||||
const on = cb.checked;
|
||||
try { localStorage.setItem('admin-view', on ? 'on' : 'off'); } catch (_) {}
|
||||
document.documentElement.classList.toggle('admin-view-off', !on);
|
||||
});
|
||||
}
|
||||
|
||||
// Render the timestamp in the browser's local timezone. Server renders UTC
|
||||
// (RFC3339) into `datetime=""`; JS formats it for display.
|
||||
const timeFmt = new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
document.querySelectorAll('.rec-time[datetime]').forEach(el => {
|
||||
const iso = el.getAttribute('datetime');
|
||||
if (!iso) return;
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return;
|
||||
el.textContent = timeFmt.format(d);
|
||||
});
|
||||
|
||||
const fmtDur = s => {
|
||||
if (!isFinite(s) || s < 0) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
document.querySelectorAll('.player').forEach(p => {
|
||||
const src = p.dataset.src;
|
||||
const btn = p.querySelector('.play');
|
||||
const seek = p.querySelector('.seek');
|
||||
const timeEl = p.querySelector('.time');
|
||||
const initialDuration = parseFloat(p.dataset.duration);
|
||||
let audio = null;
|
||||
let seeking = false;
|
||||
// Pending seek expressed as a fraction of the full length (0..1). Used
|
||||
// whenever we can't immediately apply `audio.currentTime` (metadata not
|
||||
// yet loaded, or duration unknown at drop time). Cleared in `seeked`,
|
||||
// so render() doesn't overwrite the slider with a stale currentTime
|
||||
// reading between "set" and "applied".
|
||||
let pendingSeek = null;
|
||||
let duration = isFinite(initialDuration) ? initialDuration : 0;
|
||||
|
||||
const render = () => {
|
||||
const cur = audio ? audio.currentTime : 0;
|
||||
const displayTime = pendingSeek !== null ? pendingSeek * duration : cur;
|
||||
timeEl.textContent = `${fmtDur(displayTime)} / ${fmtDur(duration)}`;
|
||||
if (!seeking && pendingSeek === null) {
|
||||
seek.value = duration > 0 ? Math.round((cur / duration) * 1000) : 0;
|
||||
}
|
||||
};
|
||||
render();
|
||||
|
||||
const tryApplyPendingSeek = () => {
|
||||
if (!audio || pendingSeek === null) return;
|
||||
if (audio.readyState >= 1 && isFinite(audio.duration) && audio.duration > 0) {
|
||||
audio.currentTime = pendingSeek * audio.duration;
|
||||
// Keep pendingSeek set until `seeked` fires — the browser needs a
|
||||
// moment to commit the seek, and any timeupdate in between would
|
||||
// otherwise read the old currentTime.
|
||||
}
|
||||
};
|
||||
|
||||
const ensureAudio = () => {
|
||||
if (audio) return audio;
|
||||
audio = new Audio(src);
|
||||
audio.preload = 'metadata';
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
if (isFinite(audio.duration) && audio.duration > 0) duration = audio.duration;
|
||||
tryApplyPendingSeek();
|
||||
render();
|
||||
});
|
||||
audio.addEventListener('timeupdate', render);
|
||||
audio.addEventListener('seeked', () => {
|
||||
pendingSeek = null;
|
||||
render();
|
||||
});
|
||||
audio.addEventListener('ended', () => {
|
||||
btn.classList.remove('is-pause');
|
||||
btn.setAttribute('aria-label', 'Abspielen');
|
||||
audio.currentTime = 0;
|
||||
render();
|
||||
});
|
||||
return audio;
|
||||
};
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
const a = ensureAudio();
|
||||
if (a.paused) {
|
||||
a.play();
|
||||
btn.classList.add('is-pause');
|
||||
btn.setAttribute('aria-label', 'Pause');
|
||||
} else {
|
||||
a.pause();
|
||||
btn.classList.remove('is-pause');
|
||||
btn.setAttribute('aria-label', 'Abspielen');
|
||||
}
|
||||
});
|
||||
|
||||
seek.addEventListener('input', () => {
|
||||
seeking = true;
|
||||
if (duration <= 0) return;
|
||||
// Live time-label update while dragging (no audio change yet).
|
||||
const preview = (parseFloat(seek.value) / 1000) * duration;
|
||||
timeEl.textContent = `${fmtDur(preview)} / ${fmtDur(duration)}`;
|
||||
});
|
||||
seek.addEventListener('change', () => {
|
||||
seeking = false;
|
||||
const fraction = parseFloat(seek.value) / 1000;
|
||||
// Ensure audio exists even if duration is still unknown — this kicks
|
||||
// off metadata loading, so loadedmetadata will apply the pending seek.
|
||||
ensureAudio();
|
||||
pendingSeek = fraction;
|
||||
tryApplyPendingSeek();
|
||||
render();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u8> = (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();
|
||||
|
||||
Reference in New Issue
Block a user