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:
2026-04-27 12:48:25 +02:00
parent a510c20e75
commit 66b3b7e4c8
20 changed files with 637 additions and 335 deletions
+27 -12
View File
@@ -11,8 +11,8 @@ use time::format_description::well_known::Rfc3339;
use tokio::io::AsyncWriteExt;
use tracing::{info, warn};
use doctate_common::TranscriptState;
use doctate_common::timestamp::{filename_stem_to_recorded_at, now_rfc3339};
use doctate_common::{RECORDING_META_SUFFIX, TranscriptState};
use crate::analyze::{
ANALYSIS_INPUT_FILE, AnalysisInput, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE, RecordingInput,
@@ -171,7 +171,7 @@ fn resolve_list_return_path(headers: &HeaderMap) -> String {
/// Build an `AnalysisInput` for the given case directory.
/// Collects all `.m4a` (excluding `.m4a.failed`), reads the matching
/// `.transcript.txt` for each, skips blank transcripts, and computes
/// `<stem>.json` for each, skips blank transcripts, and computes
/// `last_recording_mtime` as the max mtime over ALL `.m4a` (including blank
/// ones — a late blank addendum still counts as activity).
pub(crate) async fn build_analysis_input(case_dir: &Path) -> Result<AnalysisInput, AppError> {
@@ -225,7 +225,7 @@ async fn read_recordings(m4as: &[(PathBuf, SystemTime)]) -> Result<Vec<Recording
));
}
TranscriptState::Silent => continue,
TranscriptState::Content(t) => t,
TranscriptState::Content { text } => text,
};
let stem = m4a_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
@@ -322,12 +322,27 @@ pub(crate) async fn reset_case_artefacts(
}
delete_oneliner_unless_manual(case_dir, locks).await?;
let mut entries = tokio::fs::read_dir(case_dir).await?;
let meta_suffix = format!(".{RECORDING_META_SUFFIX}");
while let Some(entry) = entries.next_entry().await? {
let name = entry.file_name();
let Some(s) = name.to_str() else { continue };
if s.ends_with(".transcript.txt") {
tokio::fs::remove_file(entry.path()).await?;
} else if s.ends_with(".m4a.failed") {
// Per-recording metadata sidecar: paired with `<stem>.m4a` or
// its `.failed` twin. The pair check guards case-level JSONs
// (preserved Manual oneliner, future siblings) from being
// misidentified as recording artefacts.
if let Some(stem) = s.strip_suffix(&meta_suffix) {
let paired = tokio::fs::try_exists(case_dir.join(format!("{stem}.m4a")))
.await
.unwrap_or(false)
|| tokio::fs::try_exists(case_dir.join(format!("{stem}.m4a.failed")))
.await
.unwrap_or(false);
if paired {
tokio::fs::remove_file(entry.path()).await?;
}
continue;
}
if s.ends_with(".m4a.failed") {
let new_name = &s[..s.len() - ".failed".len()];
tokio::fs::rename(entry.path(), case_dir.join(new_name)).await?;
}
@@ -601,13 +616,13 @@ pub async fn handle_delete_recording(
.trim_end_matches(".failed")
.trim_end_matches(".m4a");
// Audio and its sidecars. NotFound on any of these is fine: the
// transcript / duration may never have been written (silence, failed
// job, or the file got deleted twice).
let targets: [PathBuf; 5] = [
// Audio and its single metadata sidecar. NotFound on any of these
// is fine: the JSON sidecar may never have been written (the
// worker hadn't reached the atomic write yet, or the file got
// deleted twice).
let targets: [PathBuf; 4] = [
case_dir.join(&form.filename),
case_dir.join(format!("{stem}.transcript.txt")),
case_dir.join(format!("{stem}.duration.txt")),
case_dir.join(format!("{stem}.{RECORDING_META_SUFFIX}")),
case_dir.join(DOCUMENT_FILE),
case_dir.join(ANALYSIS_INPUT_FILE),
];