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
+112 -17
View File
@@ -1,13 +1,47 @@
use std::path::{Path, PathBuf};
use doctate_common::TranscriptState;
use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState};
use doctate_common::{RECORDING_META_SUFFIX, RecordingMeta, TranscriptState};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use tracing::info;
use crate::oneliner_locks::OnelinerLocks;
/// Atomically write `value` as JSON to `final_path`.
///
/// Writes to `<final_path>.tmp` first, then `rename`s into place.
/// `rename` within the same filesystem is atomic on every POSIX target
/// we run on, so concurrent readers either see the previous full file
/// or the new full file — never a half-written one. Caller is
/// responsible for the destination's parent directory existing.
///
/// Used by every metadata sidecar (oneliner state, per-recording meta,
/// …) so the on-disk format never has partial documents the parser
/// would choke on.
pub async fn atomic_write_json<T: Serialize>(final_path: &Path, value: &T) -> std::io::Result<()> {
let tmp_path = match final_path.file_name() {
Some(name) => {
let mut tmp_name = name.to_owned();
tmp_name.push(".tmp");
final_path.with_file_name(tmp_name)
}
// No file_name on a path means we cannot derive a sibling tmp
// file — bail out with InvalidInput rather than silently
// writing to a wrong place.
None => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"atomic_write_json: target path has no file name",
));
}
};
let payload = serde_json::to_vec(value)
.map_err(|e| std::io::Error::other(format!("serialize json: {e}")))?;
tokio::fs::write(&tmp_path, &payload).await?;
tokio::fs::rename(&tmp_path, final_path).await
}
/// Absolute on-disk location of a case directory.
///
/// Single source of truth for the layout `<data_path>/<slug>/<case_id>/`.
@@ -86,16 +120,12 @@ pub async fn read_oneliner_state(
(Some(state), mtime)
}
/// Write `state` atomically to `case_dir/oneliner.json` via
/// `write(tmp) + rename(tmp, final)`. Rename is atomic within a single
/// filesystem, so concurrent readers never see a half-serialized JSON
/// document.
/// Write `state` atomically to `case_dir/oneliner.json`. Thin wrapper
/// over [`atomic_write_json`] that fixes the filename — kept as its own
/// function so call-sites read as "write the oneliner state" rather
/// than threading the filename constant in by hand.
pub async fn write_oneliner_state(case_dir: &Path, state: &OnelinerState) -> std::io::Result<()> {
let final_path = case_dir.join(ONELINER_FILENAME);
let tmp_path = case_dir.join(format!("{ONELINER_FILENAME}.tmp"));
let payload = serde_json::to_vec(state).expect("OnelinerState is infallibly serializable");
tokio::fs::write(&tmp_path, &payload).await?;
tokio::fs::rename(&tmp_path, &final_path).await
atomic_write_json(&case_dir.join(ONELINER_FILENAME), state).await
}
/// Delete `case_dir/oneliner.json` unless it persists a manual override.
@@ -138,21 +168,86 @@ pub async fn delete_oneliner_unless_manual(
}
}
/// Load the `.transcript.txt` sidecar for a recording as a
/// Load the `<stem>.json` metadata sidecar for a recording as a
/// [`TranscriptState`].
///
/// `audio_stem_path` is the non-`.failed` stem path of the recording
/// (e.g. `case_dir/2026-04-16T16-23-38Z.m4a`) — the sidecar sits next
/// to it with extension `.transcript.txt`.
/// to it with the `.json` extension.
///
/// This is the single reader for that sidecar. Every call-site that
/// used to do `tokio::fs::read_to_string(...).ok()` or
/// This is the single reader of that sidecar's transcript field.
/// Every call-site that used to do
/// `tokio::fs::read_to_string(...).ok()` or
/// `with_extension("transcript.txt").exists()` should go through here
/// so the three-state semantics stay consistent across the codebase.
pub async fn read_transcript_state(audio_stem_path: &Path) -> TranscriptState {
let transcript_path = audio_stem_path.with_extension("transcript.txt");
let raw = tokio::fs::read_to_string(&transcript_path).await.ok();
TranscriptState::from_raw(raw.as_deref())
TranscriptState::from_meta(read_recording_meta(audio_stem_path).await.as_ref())
}
/// Path of the per-recording metadata sidecar next to `audio_stem_path`.
///
/// `audio_stem_path` is the non-`.failed` stem path of the recording
/// (e.g. `case_dir/2026-04-16T16-23-38Z.m4a`); the sidecar lives at
/// `case_dir/2026-04-16T16-23-38Z.json`.
pub fn recording_meta_path(audio_stem_path: &Path) -> PathBuf {
audio_stem_path.with_extension(RECORDING_META_SUFFIX)
}
/// Read and parse the `<stem>.json` sidecar for a recording.
///
/// Returns `None` for missing files (the typical "transcriber hasn't
/// finished yet" case) and for malformed JSON (logged at warn — a
/// rewritten sidecar will overwrite the bad one on the next worker
/// pass). Callers translate `None` to [`TranscriptState::Pending`].
pub async fn read_recording_meta(audio_stem_path: &Path) -> Option<RecordingMeta> {
let path = recording_meta_path(audio_stem_path);
let bytes = tokio::fs::read(&path).await.ok()?;
match serde_json::from_slice(&bytes) {
Ok(meta) => Some(meta),
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"malformed recording meta sidecar; treating as missing"
);
None
}
}
}
/// Atomically write `<stem>.json` for a recording. Thin wrapper around
/// [`atomic_write_json`] that fixes the filename so call-sites read as
/// "persist this recording's metadata" rather than threading the
/// suffix in by hand.
pub async fn write_recording_meta_atomic(
audio_stem_path: &Path,
meta: &RecordingMeta,
) -> std::io::Result<()> {
atomic_write_json(&recording_meta_path(audio_stem_path), meta).await
}
/// Synchronous test fixture: write a `<stem>.json` directly with the
/// given transcript and duration. Mirrors what the worker would have
/// persisted at the end of its pipeline. Async helpers exist for
/// production paths; this one keeps unit-test setups terse and
/// callable from non-async contexts.
#[cfg(test)]
pub(crate) fn write_recording_meta_sync(
case_dir: &Path,
stem: &str,
transcript: doctate_common::Transcript,
duration_seconds: Option<u32>,
) {
let meta = doctate_common::RecordingMeta {
transcript,
duration_seconds,
};
let bytes = serde_json::to_vec(&meta).expect("RecordingMeta serializes infallibly");
std::fs::write(
case_dir.join(format!("{stem}.{RECORDING_META_SUFFIX}")),
bytes,
)
.expect("write recording meta sidecar in test fixture");
}
#[cfg(test)]