diff --git a/server/src/routes/web.rs b/server/src/routes/web.rs index 221b483..b356ed0 100644 --- a/server/src/routes/web.rs +++ b/server/src/routes/web.rs @@ -14,6 +14,9 @@ use crate::error::AppError; struct RecordingView { filename: String, transcript: Option, + /// True if the file has been renamed to `.m4a.failed` by the worker + /// after a non-recoverable ffmpeg or whisper error. + failed: bool, } struct CaseView { @@ -80,7 +83,8 @@ fn validate_user_slug(user: &str) -> Result<(), AppError> { } fn validate_filename(filename: &str) -> Result<(), AppError> { - if !filename.ends_with(".m4a") + let ok_suffix = filename.ends_with(".m4a") || filename.ends_with(".m4a.failed"); + if !ok_suffix || filename.contains('/') || filename.contains('\\') || filename.contains("..") @@ -178,12 +182,21 @@ async fn scan_recordings(case_dir: &Path) -> Vec { Ok(s) => s, Err(_) => continue, }; - if !filename.ends_with(".m4a") { + let failed = filename.ends_with(".m4a.failed"); + let is_audio = filename.ends_with(".m4a") || failed; + if !is_audio { continue; } - let transcript_path = case_dir.join(&filename).with_extension("transcript.txt"); + // 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")); + let transcript_path = stem_path.with_extension("transcript.txt"); let transcript = tokio::fs::read_to_string(&transcript_path).await.ok(); - recordings.push(RecordingView { filename, transcript }); + recordings.push(RecordingView { + filename, + transcript, + failed, + }); } recordings.sort_by(|a, b| a.filename.cmp(&b.filename)); diff --git a/server/src/transcribe/worker.rs b/server/src/transcribe/worker.rs index 3548ac0..a08fd78 100644 --- a/server/src/transcribe/worker.rs +++ b/server/src/transcribe/worker.rs @@ -39,6 +39,7 @@ pub async fn run(mut rx: TranscribeReceiver, config: Arc, client: reqwes Ok(f) => f, Err(e) => { error!(audio = %audio_path.display(), error = %e, "ffmpeg remux failed"); + mark_failed(&audio_path).await; continue; } }; @@ -47,6 +48,7 @@ pub async fn run(mut rx: TranscribeReceiver, config: Arc, client: reqwes Ok(t) => t, Err(e) => { error!(audio = %audio_path.display(), error = %e, "whisper call failed"); + mark_failed(&audio_path).await; continue; } }; @@ -70,6 +72,29 @@ pub async fn run(mut rx: TranscribeReceiver, config: Arc, client: reqwes warn!("Transcription worker stopped (channel closed)"); } +/// Atomically rename a failed recording from `.m4a` to `.m4a.failed` +/// so the recovery scan no longer picks it up. The UI still surfaces these +/// files (with a "failed" flag) so the user can listen to the audio and +/// manually decide whether to retry (by renaming back) or to delete. +async fn mark_failed(audio_path: &Path) { + let failed_path = audio_path.with_extension("m4a.failed"); + if let Err(e) = tokio::fs::rename(audio_path, &failed_path).await { + // Nothing to roll back — if rename fails, the worst case is that we + // retry the same file on next restart. Log and move on. + error!( + audio = %audio_path.display(), + error = %e, + "Failed to mark recording as failed; may be retried on restart", + ); + } else { + warn!( + audio = %audio_path.display(), + failed = %failed_path.display(), + "Recording marked as failed", + ); + } +} + /// Generate `case_dir/oneliner.txt` from the given transcript iff the file /// does not already exist. Errors are non-fatal: logged and swallowed. async fn ensure_oneliner( diff --git a/server/templates/cases.html b/server/templates/cases.html index 22f7d64..0a8ff20 100644 --- a/server/templates/cases.html +++ b/server/templates/cases.html @@ -15,6 +15,8 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1 .recording-head span { font-family: monospace; font-size: 0.9em; min-width: 20em; } .transcript { margin: 0.5em 0 0 1em; padding: 0.5em; background: #f6f6f6; border-radius: 4px; white-space: pre-wrap; font-family: serif; } .pending { margin: 0.5em 0 0 1em; color: #888; font-style: italic; } +.failed { margin: 0.5em 0 0 1em; color: #b00; font-weight: bold; } +.recording.failed-row { background: #fff7f7; border-left: 3px solid #e24a4a; padding-left: 0.5em; } .oneliner { font-size: 1em; color: #333; margin: 0 0 0.3em 0; } @@ -33,19 +35,23 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1 {% endmatch %} {{ case.case_id }} {% for rec in case.recordings %} -
+
{{ rec.filename }}
+{% if rec.failed %} +
Transcription failed — rename away the .failed suffix to retry.
+{% else %} {% match rec.transcript %} {% when Some with (t) %}
{{ t }}
{% when None %}
Transcription pending…
{% endmatch %} +{% endif %}
{% endfor %}
diff --git a/server/tests/transcribe_test.rs b/server/tests/transcribe_test.rs index bf87108..5a807d5 100644 --- a/server/tests/transcribe_test.rs +++ b/server/tests/transcribe_test.rs @@ -1,11 +1,14 @@ use std::path::PathBuf; use std::time::Duration; +use std::collections::HashMap; +use std::sync::Arc; + +use doctate_server::config::{Config, User, WhisperUserSettings}; use doctate_server::transcribe; use doctate_server::transcribe::ffmpeg::remux_faststart; use doctate_server::transcribe::ollama::{generate_oneliner, OllamaError}; use doctate_server::transcribe::recovery::scan_and_enqueue; -use doctate_server::config::WhisperUserSettings; use doctate_server::transcribe::whisper::{transcribe, WhisperError}; use serde_json::json; use wiremock::matchers::{body_partial_json, method, path, query_param}; @@ -242,6 +245,75 @@ async fn recovery_enqueues_only_pending_recordings() { assert!(received[1].ends_with("2026-04-12T10-00-00Z.m4a")); } +// -------------------- Worker: failure marker -------------------- + +fn test_config_with_whisper(whisper_url: String) -> Arc { + Arc::new(Config { + server_port: 3000, + data_path: "/tmp".into(), + log_level: "info".into(), + log_path: "/tmp".into(), + log_max_days: 90, + users: vec![User { + slug: "dr_test".into(), + api_key: "k".into(), + web_password: "unused".into(), + role: "doctor".into(), + whisper: Default::default(), + }], + api_keys: HashMap::new(), + retention_audio_days: 30, + retention_transcript_days: 30, + retention_document_days: 0, + whisper_url, + whisper_timeout_seconds: 5, + // Ollama URL is irrelevant — worker never reaches it in the failure path. + ollama_url: "http://127.0.0.1:1".into(), + ollama_model: "test".into(), + ollama_keep_alive: 0, + llm_url: String::new(), + llm_api_key: String::new(), + llm_model: String::new(), + llm_temperature: 0.0, + session_timeout_hours: 8, + }) +} + +#[tokio::test] +async fn worker_renames_audio_to_failed_on_whisper_error() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/asr")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .mount(&server) + .await; + + // Real case dir with a real m4a fixture so ffmpeg remux succeeds. + let tmp = tempfile::tempdir().unwrap(); + let case_dir = tmp.path().join("dr_test/open/aaaa"); + std::fs::create_dir_all(&case_dir).unwrap(); + let audio = case_dir.join("2026-04-13T10-30-00Z.m4a"); + std::fs::copy(fixture("sample.m4a"), &audio).unwrap(); + + let config = test_config_with_whisper(server.uri()); + let (tx, rx) = transcribe::channel(); + tx.send(transcribe::TranscribeJob { + audio_path: audio.clone(), + user_slug: "dr_test".into(), + }) + .await + .unwrap(); + drop(tx); // close channel so the worker loop exits after processing the job + + let client = reqwest::Client::new(); + transcribe::worker::run(rx, config, client).await; + + // Audio must have been renamed so recovery skips it next time. + assert!(!audio.exists(), "original .m4a still present"); + let failed = case_dir.join("2026-04-13T10-30-00Z.m4a.failed"); + assert!(failed.exists(), "expected {} to exist", failed.display()); +} + // -------------------- Ollama client -------------------- const MODEL: &str = "gemma3:4b";