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:
2026-04-14 14:32:22 +02:00
parent 29d3476e3e
commit 61b5c8e125
4 changed files with 122 additions and 6 deletions
+17 -4
View File
@@ -14,6 +14,9 @@ use crate::error::AppError;
struct RecordingView {
filename: 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 {
@@ -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<RecordingView> {
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 `<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();
recordings.push(RecordingView { filename, transcript });
recordings.push(RecordingView {
filename,
transcript,
failed,
});
}
recordings.sort_by(|a, b| a.filename.cmp(&b.filename));
+25
View File
@@ -39,6 +39,7 @@ pub async fn run(mut rx: TranscribeReceiver, config: Arc<Config>, 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<Config>, 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<Config>, client: reqwes
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
/// does not already exist. Errors are non-fatal: logged and swallowed.
async fn ensure_oneliner(