feat: Mark failed recordings with .m4a.failed
Add a new `failed` field to `RecordingView` and corresponding logic in the worker. When FFmpeg remuxing or Whisper transcription fails, the worker now renames the original `.m4a` file to `.m4a.failed`. This prevents the recovery scan from reprocessing these files and signals to the UI that transcription failed. The UI displays a message indicating failure and suggests renaming the file back to `.m4a` to retry. A new integration test `worker_renames_audio_to_failed_on_whisper_error` is included to verify this behavior.
This commit is contained in:
@@ -14,6 +14,9 @@ use crate::error::AppError;
|
|||||||
struct RecordingView {
|
struct RecordingView {
|
||||||
filename: String,
|
filename: String,
|
||||||
transcript: Option<String>,
|
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.
|
||||||
|
failed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CaseView {
|
struct CaseView {
|
||||||
@@ -80,7 +83,8 @@ fn validate_user_slug(user: &str) -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_filename(filename: &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('\\')
|
|| filename.contains('\\')
|
||||||
|| filename.contains("..")
|
|| filename.contains("..")
|
||||||
@@ -178,12 +182,21 @@ async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
|
|||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(_) => continue,
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
let transcript_path = case_dir.join(&filename).with_extension("transcript.txt");
|
// 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"));
|
||||||
|
let transcript_path = stem_path.with_extension("transcript.txt");
|
||||||
let transcript = tokio::fs::read_to_string(&transcript_path).await.ok();
|
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));
|
recordings.sort_by(|a, b| a.filename.cmp(&b.filename));
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ pub async fn run(mut rx: TranscribeReceiver, config: Arc<Config>, client: reqwes
|
|||||||
Ok(f) => f,
|
Ok(f) => f,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(audio = %audio_path.display(), error = %e, "ffmpeg remux failed");
|
error!(audio = %audio_path.display(), error = %e, "ffmpeg remux failed");
|
||||||
|
mark_failed(&audio_path).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -47,6 +48,7 @@ pub async fn run(mut rx: TranscribeReceiver, config: Arc<Config>, client: reqwes
|
|||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(audio = %audio_path.display(), error = %e, "whisper call failed");
|
error!(audio = %audio_path.display(), error = %e, "whisper call failed");
|
||||||
|
mark_failed(&audio_path).await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -70,6 +72,29 @@ pub async fn run(mut rx: TranscribeReceiver, config: Arc<Config>, client: reqwes
|
|||||||
warn!("Transcription worker stopped (channel closed)");
|
warn!("Transcription worker stopped (channel closed)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atomically rename a failed recording from `<ts>.m4a` to `<ts>.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
|
/// Generate `case_dir/oneliner.txt` from the given transcript iff the file
|
||||||
/// does not already exist. Errors are non-fatal: logged and swallowed.
|
/// does not already exist. Errors are non-fatal: logged and swallowed.
|
||||||
async fn ensure_oneliner(
|
async fn ensure_oneliner(
|
||||||
|
|||||||
@@ -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; }
|
.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; }
|
.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; }
|
.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; }
|
.oneliner { font-size: 1em; color: #333; margin: 0 0 0.3em 0; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -33,19 +35,23 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1
|
|||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
<small>{{ case.case_id }}</small>
|
<small>{{ case.case_id }}</small>
|
||||||
{% for rec in case.recordings %}
|
{% for rec in case.recordings %}
|
||||||
<div class="recording">
|
<div class="recording{% if rec.failed %} failed-row{% endif %}">
|
||||||
<div class="recording-head">
|
<div class="recording-head">
|
||||||
<span>{{ rec.filename }}</span>
|
<span>{{ rec.filename }}</span>
|
||||||
<audio controls preload="none">
|
<audio controls preload="none">
|
||||||
<source src="/web/audio/{{ case.user_slug }}/{{ case.case_id }}/{{ rec.filename }}" type="audio/mp4">
|
<source src="/web/audio/{{ case.user_slug }}/{{ case.case_id }}/{{ rec.filename }}" type="audio/mp4">
|
||||||
</audio>
|
</audio>
|
||||||
</div>
|
</div>
|
||||||
|
{% if rec.failed %}
|
||||||
|
<div class="failed">Transcription failed — rename away the .failed suffix to retry.</div>
|
||||||
|
{% else %}
|
||||||
{% match rec.transcript %}
|
{% match rec.transcript %}
|
||||||
{% when Some with (t) %}
|
{% when Some with (t) %}
|
||||||
<div class="transcript">{{ t }}</div>
|
<div class="transcript">{{ t }}</div>
|
||||||
{% when None %}
|
{% when None %}
|
||||||
<div class="pending">Transcription pending…</div>
|
<div class="pending">Transcription pending…</div>
|
||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
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;
|
||||||
use doctate_server::transcribe::ffmpeg::remux_faststart;
|
use doctate_server::transcribe::ffmpeg::remux_faststart;
|
||||||
use doctate_server::transcribe::ollama::{generate_oneliner, OllamaError};
|
use doctate_server::transcribe::ollama::{generate_oneliner, OllamaError};
|
||||||
use doctate_server::transcribe::recovery::scan_and_enqueue;
|
use doctate_server::transcribe::recovery::scan_and_enqueue;
|
||||||
use doctate_server::config::WhisperUserSettings;
|
|
||||||
use doctate_server::transcribe::whisper::{transcribe, WhisperError};
|
use doctate_server::transcribe::whisper::{transcribe, WhisperError};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use wiremock::matchers::{body_partial_json, method, path, query_param};
|
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"));
|
assert!(received[1].ends_with("2026-04-12T10-00-00Z.m4a"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------- Worker: failure marker --------------------
|
||||||
|
|
||||||
|
fn test_config_with_whisper(whisper_url: String) -> Arc<Config> {
|
||||||
|
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 --------------------
|
// -------------------- Ollama client --------------------
|
||||||
|
|
||||||
const MODEL: &str = "gemma3:4b";
|
const MODEL: &str = "gemma3:4b";
|
||||||
|
|||||||
Reference in New Issue
Block a user