Refactor recording metadata to JSON sidecar
Replaces the `.transcript.txt` sidecar with a structured `.json` file for recording metadata. This change consolidates transcript text, duration, and other potential metadata into a single, extensible JSON object. This also refactors the `TranscriptState` enum to better represent the on-disk state (absence of file means pending) and the in-memory representation. The `Transcript` enum now specifically models the terminal outcomes of the transcriber (`Silent` or `Content`). The commit includes updates to documentation, data structures, path handling, and various tests to align with the new metadata format.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use doctate_common::RECORDING_META_SUFFIX;
|
||||
use doctate_common::oneliners::OnelinerState;
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -35,8 +36,9 @@ pub async fn scan_and_enqueue(data_path: &Path, tx: &TranscribeSender) {
|
||||
}
|
||||
|
||||
/// Scan a single `<user_root>/<case>/*.m4a` set and enqueue every recording
|
||||
/// whose `.transcript.txt` is missing. Returns the number sent. Channel send
|
||||
/// is awaited (bounded channel); only fails if the worker is gone.
|
||||
/// whose metadata sidecar (`<stem>.json`) is missing. Returns the number
|
||||
/// sent. Channel send is awaited (bounded channel); only fails if the
|
||||
/// worker is gone.
|
||||
pub async fn enqueue_pending_for_user(
|
||||
user_root: &Path,
|
||||
slug: &str,
|
||||
@@ -165,13 +167,17 @@ pub(crate) async fn cases_needing_oneliner_in(user_root: &Path) -> Vec<PathBuf>
|
||||
/// meaning the transcribe pipeline will produce no further artefacts
|
||||
/// and `update_oneliner` has enough information to settle the case.
|
||||
///
|
||||
/// Three terminal shapes count:
|
||||
/// - `.transcript.txt` with content → [`TranscriptState::Content`]
|
||||
/// - `.transcript.txt` empty → [`TranscriptState::Silent`]
|
||||
/// - `.m4a.failed` → permanent transcription failure (ffmpeg remux or
|
||||
/// Whisper 4xx). Transient Whisper errors leave the file as plain
|
||||
/// `.m4a` without sidecar (Pending), so they do *not* match here —
|
||||
/// the page-load heal re-enqueues them on the next refresh.
|
||||
/// Two terminal shapes count:
|
||||
/// - `<stem>.json` next to `<stem>.m4a` → transcriber wrote its
|
||||
/// metadata sidecar (carries either Silent or Content). The
|
||||
/// `<stem>.m4a` pairing is required because the case directory also
|
||||
/// holds case-level JSONs (`oneliner.json`, `analysis_input.json`)
|
||||
/// that must not be misread as a recording sidecar.
|
||||
/// - `<stem>.m4a.failed` → permanent transcription failure (ffmpeg
|
||||
/// remux or Whisper 4xx). Transient Whisper errors leave the file
|
||||
/// as plain `.m4a` without sidecar (Pending), so they do *not*
|
||||
/// match here — the page-load heal re-enqueues them on the next
|
||||
/// refresh.
|
||||
///
|
||||
/// Cases where every recording is still `Pending` (plain `.m4a`, no
|
||||
/// sidecar, not marked failed) return false: the transcriber hasn't
|
||||
@@ -186,9 +192,19 @@ async fn has_any_transcript(case_dir: &Path) -> bool {
|
||||
return false;
|
||||
};
|
||||
while let Ok(Some(f)) = files.next_entry().await {
|
||||
if f.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|s| s.ends_with(".transcript.txt") || s.ends_with(".m4a.failed"))
|
||||
let Some(name) = f.file_name().to_str().map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
if name.ends_with(".m4a.failed") {
|
||||
return true;
|
||||
}
|
||||
// A per-recording meta sidecar's stem matches its audio's
|
||||
// stem. Pair the JSON with `<stem>.m4a` to filter out
|
||||
// case-level JSONs (oneliner.json, analysis_input.json).
|
||||
if let Some(stem) = name.strip_suffix(&format!(".{RECORDING_META_SUFFIX}"))
|
||||
&& tokio::fs::try_exists(case_dir.join(format!("{stem}.m4a")))
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -246,16 +262,33 @@ pub async fn regenerate_missing_oneliners_for_user(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use doctate_common::Transcript;
|
||||
use tempfile::tempdir;
|
||||
|
||||
/// Seed `<stem>.m4a` plus its `<stem>.json` metadata sidecar — the
|
||||
/// shape `has_any_transcript` recognises as terminal. The pair
|
||||
/// (audio + sidecar) is required because recovery filters
|
||||
/// case-level JSONs from the per-recording set by checking for an
|
||||
/// audio sibling.
|
||||
async fn seed_recording(case: &Path, stem: &str, transcript: Transcript) {
|
||||
tokio::fs::write(case.join(format!("{stem}.m4a")), b"audio")
|
||||
.await
|
||||
.unwrap();
|
||||
crate::paths::write_recording_meta_sync(case, stem, transcript, None);
|
||||
}
|
||||
|
||||
fn content(text: &str) -> Transcript {
|
||||
Transcript::Content {
|
||||
text: text.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cases_needing_oneliner_finds_crash_survivor() {
|
||||
let data = tempdir().unwrap();
|
||||
let case = data.path().join("user").join("case1");
|
||||
tokio::fs::create_dir_all(&case).await.unwrap();
|
||||
tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), "content")
|
||||
.await
|
||||
.unwrap();
|
||||
seed_recording(&case, "2026-04-16T10-00-00Z", content("content")).await;
|
||||
|
||||
let got = cases_needing_oneliner(data.path()).await;
|
||||
assert_eq!(got, vec![(case, "user".to_owned())]);
|
||||
@@ -273,9 +306,7 @@ mod tests {
|
||||
let data = tempdir().unwrap();
|
||||
let case = data.path().join("user").join("case1");
|
||||
tokio::fs::create_dir_all(&case).await.unwrap();
|
||||
tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), "content")
|
||||
.await
|
||||
.unwrap();
|
||||
seed_recording(&case, "2026-04-16T10-00-00Z", content("content")).await;
|
||||
write_state(
|
||||
&case,
|
||||
&OnelinerState::Ready {
|
||||
@@ -293,9 +324,7 @@ mod tests {
|
||||
let data = tempdir().unwrap();
|
||||
let case = data.path().join("user").join("case1");
|
||||
tokio::fs::create_dir_all(&case).await.unwrap();
|
||||
tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), "content")
|
||||
.await
|
||||
.unwrap();
|
||||
seed_recording(&case, "2026-04-16T10-00-00Z", content("content")).await;
|
||||
write_state(
|
||||
&case,
|
||||
&OnelinerState::Empty {
|
||||
@@ -312,9 +341,7 @@ mod tests {
|
||||
let data = tempdir().unwrap();
|
||||
let case = data.path().join("user").join("case1");
|
||||
tokio::fs::create_dir_all(&case).await.unwrap();
|
||||
tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), "content")
|
||||
.await
|
||||
.unwrap();
|
||||
seed_recording(&case, "2026-04-16T10-00-00Z", content("content")).await;
|
||||
write_state(
|
||||
&case,
|
||||
&OnelinerState::Error {
|
||||
@@ -327,27 +354,25 @@ mod tests {
|
||||
assert_eq!(got, vec![(case, "user".to_owned())]);
|
||||
}
|
||||
|
||||
/// Silent-only cases (transcript file exists but is empty/whitespace)
|
||||
/// are now picked up by recovery so `update_oneliner` can settle
|
||||
/// them to [`OnelinerState::Empty`]. Previously they were skipped,
|
||||
/// which left the UI stuck on "Generating" forever (the bug this
|
||||
/// enum refactor fixes).
|
||||
/// Silent-only cases (sidecar carries `Transcript::Silent`) are
|
||||
/// picked up by recovery so `update_oneliner` can settle them to
|
||||
/// [`OnelinerState::Empty`]. Previously they were skipped, which
|
||||
/// left the UI stuck on "Generating" forever (the bug the
|
||||
/// three-state model fixes).
|
||||
#[tokio::test]
|
||||
async fn cases_needing_oneliner_picks_up_silent_only_cases_for_empty_settlement() {
|
||||
let data = tempdir().unwrap();
|
||||
let case = data.path().join("user").join("case1");
|
||||
tokio::fs::create_dir_all(&case).await.unwrap();
|
||||
tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), " ")
|
||||
.await
|
||||
.unwrap();
|
||||
seed_recording(&case, "2026-04-16T10-00-00Z", Transcript::Silent).await;
|
||||
|
||||
let got = cases_needing_oneliner(data.path()).await;
|
||||
assert_eq!(got, vec![(case, "user".to_owned())]);
|
||||
}
|
||||
|
||||
/// Fully-pending cases (no transcript file yet) are skipped —
|
||||
/// Fully-pending cases (no sidecar yet) are skipped —
|
||||
/// `update_oneliner` has nothing to act on until the transcriber
|
||||
/// writes a sidecar.
|
||||
/// writes one.
|
||||
#[tokio::test]
|
||||
async fn cases_needing_oneliner_skips_fully_pending_cases() {
|
||||
let data = tempdir().unwrap();
|
||||
@@ -356,7 +381,7 @@ mod tests {
|
||||
tokio::fs::write(case.join("2026-04-16T10-00-00Z.m4a"), b"audio")
|
||||
.await
|
||||
.unwrap();
|
||||
// No .transcript.txt — the recording is still Pending.
|
||||
// No <stem>.json — the recording is still Pending.
|
||||
|
||||
assert!(cases_needing_oneliner(data.path()).await.is_empty());
|
||||
}
|
||||
@@ -366,9 +391,7 @@ mod tests {
|
||||
let data = tempdir().unwrap();
|
||||
let case = data.path().join("user").join("case1");
|
||||
tokio::fs::create_dir_all(&case).await.unwrap();
|
||||
tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), "content")
|
||||
.await
|
||||
.unwrap();
|
||||
seed_recording(&case, "2026-04-16T10-00-00Z", content("content")).await;
|
||||
// Empty JSON object is treated as a valid close marker
|
||||
// (see paths::is_closed — existence of the file is enough).
|
||||
tokio::fs::write(case.join(".closed"), "{}").await.unwrap();
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use doctate_common::TranscriptState;
|
||||
use doctate_common::oneliners::OnelinerState;
|
||||
use doctate_common::{RecordingMeta, Transcript, TranscriptState};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use tracing::{error, info, warn};
|
||||
@@ -38,9 +38,13 @@ pub async fn run(
|
||||
while let Some(job) = rx.recv().await {
|
||||
let _guard = BusyGuard::new(worker_busy.clone());
|
||||
let audio_path = job.audio_path;
|
||||
let transcript_path = audio_path.with_extension("transcript.txt");
|
||||
let meta_path = paths::recording_meta_path(&audio_path);
|
||||
|
||||
if transcript_path.exists() {
|
||||
// Idempotency gate: presence of `<stem>.json` means the
|
||||
// transcriber already finished this recording (single atomic
|
||||
// write at the end of the pipeline). Skip the job, freeing
|
||||
// Whisper for the next one.
|
||||
if meta_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -65,11 +69,24 @@ 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;
|
||||
// Probe the container duration on the remuxed copy (its `moov`
|
||||
// atom sits at the start, so ffprobe returns without seeking
|
||||
// to EOF). We carry the value in memory and persist it
|
||||
// together with the transcript at the end of the pipeline.
|
||||
// Non-fatal: a probe failure leaves `duration_seconds = None`
|
||||
// and the recordings scan falls back to a read-only ffprobe
|
||||
// for the UI render.
|
||||
let duration_seconds = match ffmpeg::probe_duration_seconds(remuxed.path()).await {
|
||||
Ok(s) => Some(s),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
audio = %audio_path.display(),
|
||||
error = %e,
|
||||
"ffprobe duration failed — meta will record null duration"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let text = match whisper::transcribe(
|
||||
&client,
|
||||
@@ -106,15 +123,31 @@ pub async fn run(
|
||||
// the canonical path.
|
||||
let text = vocab.replace(&text);
|
||||
|
||||
if let Err(e) = tokio::fs::write(&transcript_path, &text).await {
|
||||
error!(transcript = %transcript_path.display(), error = %e, "writing transcript failed");
|
||||
// Single atomic write: assemble the recording metadata
|
||||
// (transcript outcome + duration) in memory, then materialise
|
||||
// it on disk in one rename. Either readers see the previous
|
||||
// state (no sidecar = pending) or the full new sidecar — never
|
||||
// a half-written one.
|
||||
let bytes_logged = text.len();
|
||||
let transcript = if text.trim().is_empty() {
|
||||
Transcript::Silent
|
||||
} else {
|
||||
Transcript::Content { text }
|
||||
};
|
||||
let meta = RecordingMeta {
|
||||
transcript,
|
||||
duration_seconds,
|
||||
};
|
||||
if let Err(e) = paths::write_recording_meta_atomic(&audio_path, &meta).await {
|
||||
error!(meta = %meta_path.display(), error = %e, "writing recording meta failed");
|
||||
continue;
|
||||
}
|
||||
|
||||
info!(
|
||||
audio = %audio_path.display(),
|
||||
bytes = text.len(),
|
||||
"Transcript written"
|
||||
bytes = bytes_logged,
|
||||
duration = ?duration_seconds,
|
||||
"Recording meta written"
|
||||
);
|
||||
|
||||
// Notify the live-update bus so subscribed browsers can refresh the
|
||||
@@ -185,23 +218,6 @@ async fn mark_failed(audio_path: &Path, events_tx: &EventSender, user_slug: &str
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.json` from **all** `Content` transcripts
|
||||
/// in the case, joined chronologically. Called after every successful
|
||||
/// transcript write so later recordings can correct earlier ones (mirror
|
||||
@@ -408,7 +424,7 @@ pub(crate) async fn all_transcripts_joined(case_dir: &Path) -> Option<String> {
|
||||
|
||||
let mut parts: Vec<String> = Vec::with_capacity(m4as.len());
|
||||
for path in m4as {
|
||||
if let TranscriptState::Content(text) = paths::read_transcript_state(&path).await {
|
||||
if let TranscriptState::Content { text } = paths::read_transcript_state(&path).await {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() {
|
||||
parts.push(trimmed.to_string());
|
||||
@@ -426,18 +442,27 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
/// Helper: seed an m4a plus an optional transcript sidecar, matching
|
||||
/// Helper: seed an m4a plus an optional metadata sidecar, matching
|
||||
/// how the worker lays files out on disk. `all_transcripts_joined`
|
||||
/// iterates `.m4a` files and reads the sidecar through the shared
|
||||
/// reader, so bare transcripts without m4as would be ignored.
|
||||
/// reader, so bare meta files without m4as would be ignored.
|
||||
///
|
||||
/// `transcript`: `None` → no sidecar (Pending); `Some(s)` with
|
||||
/// whitespace-only `s` → `Transcript::Silent`; otherwise
|
||||
/// `Transcript::Content`.
|
||||
async fn seed(dir: &Path, stem: &str, transcript: Option<&str>) {
|
||||
tokio::fs::write(dir.join(format!("{stem}.m4a")), b"audio")
|
||||
.await
|
||||
.unwrap();
|
||||
if let Some(text) = transcript {
|
||||
tokio::fs::write(dir.join(format!("{stem}.transcript.txt")), text)
|
||||
.await
|
||||
.unwrap();
|
||||
let variant = if text.trim().is_empty() {
|
||||
Transcript::Silent
|
||||
} else {
|
||||
Transcript::Content {
|
||||
text: text.to_owned(),
|
||||
}
|
||||
};
|
||||
paths::write_recording_meta_sync(dir, stem, variant, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,15 +528,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn has_pending_recordings_true_when_m4a_without_transcript() {
|
||||
let dir = tempdir().unwrap();
|
||||
tokio::fs::write(dir.path().join("2026-04-16T10-00-00Z.m4a"), b"a")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(
|
||||
dir.path().join("2026-04-16T10-00-00Z.transcript.txt"),
|
||||
"text",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
seed(dir.path(), "2026-04-16T10-00-00Z", Some("text")).await;
|
||||
tokio::fs::write(dir.path().join("2026-04-16T10-05-00Z.m4a"), b"b")
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -521,24 +538,8 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn has_pending_recordings_false_when_all_transcribed() {
|
||||
let dir = tempdir().unwrap();
|
||||
tokio::fs::write(dir.path().join("2026-04-16T10-00-00Z.m4a"), b"a")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(
|
||||
dir.path().join("2026-04-16T10-00-00Z.transcript.txt"),
|
||||
"text1",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(dir.path().join("2026-04-16T10-05-00Z.m4a"), b"b")
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(
|
||||
dir.path().join("2026-04-16T10-05-00Z.transcript.txt"),
|
||||
"text2",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
seed(dir.path(), "2026-04-16T10-00-00Z", Some("text1")).await;
|
||||
seed(dir.path(), "2026-04-16T10-05-00Z", Some("text2")).await;
|
||||
assert!(!has_pending_recordings(dir.path()).await);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user