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:
+112
-17
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user