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:
@@ -13,6 +13,6 @@ pub use constants::{
|
||||
UPLOAD_PATH,
|
||||
};
|
||||
pub use oneliners::{ONELINER_FILENAME, ONELINERS_PATH, OnelinerEntry, OnelinersResponse};
|
||||
pub use recordings::TranscriptState;
|
||||
pub use recordings::{RECORDING_META_SUFFIX, RecordingMeta, Transcript, TranscriptState};
|
||||
pub use timestamp::{filename_stem_to_recorded_at, now_rfc3339, recorded_at_to_filename_stem};
|
||||
pub use url::join_url;
|
||||
|
||||
@@ -1,56 +1,108 @@
|
||||
//! Recording-side domain types shared between server and clients.
|
||||
//!
|
||||
//! The recording lifecycle on disk has three disjoint states for the
|
||||
//! `.transcript.txt` sidecar, which is easy to conflate because the
|
||||
//! filesystem collapses two of them onto "file exists":
|
||||
//! Each recording has a single sidecar JSON next to its audio file:
|
||||
//! `<stem>.json` (suffix [`RECORDING_META_SUFFIX`]). It carries the
|
||||
//! container duration and the transcript outcome in one structured
|
||||
//! document.
|
||||
//!
|
||||
//! 1. `Pending` — no file yet, the transcriber hasn't run (or hasn't
|
||||
//! finished) for this recording.
|
||||
//! 2. `Silent` — file exists but is empty / whitespace-only. The
|
||||
//! transcriber classified the audio as silence. Terminal: the next
|
||||
//! transcribe pass will not overwrite it unless the user re-uploads.
|
||||
//! 3. `Content` — file exists with real text.
|
||||
//! On-disk vs. in-memory representation:
|
||||
//!
|
||||
//! - [`Transcript`] is the **on-disk** shape. It only models the two
|
||||
//! *terminal* outcomes the transcriber can persist (`Silent` or
|
||||
//! `Content`). A recording that has not been transcribed yet has no
|
||||
//! sidecar at all — there is no "pending" variant on disk.
|
||||
//! - [`TranscriptState`] is the **in-memory** shape returned by the
|
||||
//! server's reader. It adds a [`TranscriptState::Pending`] variant
|
||||
//! for "no sidecar exists", so callers can `match` on the three
|
||||
//! logically distinct states without having to inspect the
|
||||
//! filesystem themselves.
|
||||
//!
|
||||
//! Call-sites that collapse this onto a 2-state view (`Option<String>`,
|
||||
//! `.exists()`, `is_empty()`) drift apart and have caused real bugs:
|
||||
//! e.g. the UI treating `Silent` like `Content` while the oneliner
|
||||
//! worker treated it like `Pending`, leaving cases stuck in
|
||||
//! "Generating" forever.
|
||||
//!
|
||||
//! `TranscriptState` is the canonical representation. It is parse-only
|
||||
//! (no I/O) so this crate stays dependency-free; the server does the
|
||||
//! actual file read via `server::paths::read_transcript_state`.
|
||||
|
||||
/// Three-way state of a recording's `.transcript.txt` sidecar.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Filename suffix of the per-recording metadata JSON sidecar.
|
||||
///
|
||||
/// Used as `<audio_stem>.<RECORDING_META_SUFFIX>` next to the `.m4a`,
|
||||
/// e.g. `2026-04-19T10-00-00Z.json`. Centralised here so the server
|
||||
/// reader, worker writer, and recovery scan agree on one location.
|
||||
pub const RECORDING_META_SUFFIX: &str = "json";
|
||||
|
||||
/// On-disk transcript outcome.
|
||||
///
|
||||
/// Internally tagged: serializes as `{"state": "silent"}` or
|
||||
/// `{"state": "content", "text": "..."}` — the discriminator lives
|
||||
/// inside the JSON object next to the data, so the wire format stays
|
||||
/// flat. Only the two terminal variants the transcriber can produce
|
||||
/// appear here; "not transcribed yet" is represented by the absence
|
||||
/// of the sidecar file (see [`TranscriptState::Pending`]).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "state", rename_all = "lowercase")]
|
||||
pub enum Transcript {
|
||||
/// Transcriber ran and classified the audio as silence — terminal.
|
||||
/// The next transcribe pass will not re-run unless the user
|
||||
/// re-uploads.
|
||||
Silent,
|
||||
/// Transcriber produced non-empty text.
|
||||
Content { text: String },
|
||||
}
|
||||
|
||||
/// Per-recording metadata persisted as `<stem>.json`.
|
||||
///
|
||||
/// Single atomic write at the end of the transcribe pipeline. The
|
||||
/// presence of this file is the canonical signal "the transcriber
|
||||
/// finished for this recording". Absent file = still pending.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RecordingMeta {
|
||||
/// Transcriber outcome. See [`Transcript`].
|
||||
pub transcript: Transcript,
|
||||
/// Container duration in whole seconds, from `ffprobe` on the
|
||||
/// remuxed audio. `None` is allowed so test fixtures and recovery
|
||||
/// flows that mint a metadata file without invoking ffprobe stay
|
||||
/// valid; production worker writes always populate this.
|
||||
pub duration_seconds: Option<u32>,
|
||||
}
|
||||
|
||||
/// Three-way state of a recording's transcript, as seen by the server
|
||||
/// after consulting the on-disk sidecar.
|
||||
///
|
||||
/// Introduced to force every call-site to decide explicitly how it
|
||||
/// treats `Silent` recordings via `match` exhaustiveness.
|
||||
/// treats `Silent` recordings via `match` exhaustiveness. Unlike
|
||||
/// [`Transcript`], this carries a [`Self::Pending`] variant — the
|
||||
/// reader synthesises that when the sidecar JSON does not exist.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TranscriptState {
|
||||
/// No `.transcript.txt` file yet — transcriber hasn't produced
|
||||
/// a result. Callers should typically wait or enqueue.
|
||||
/// No sidecar JSON yet — transcriber hasn't produced a result.
|
||||
/// Callers should typically wait or enqueue.
|
||||
Pending,
|
||||
/// File exists but is empty or whitespace-only. Terminal state:
|
||||
/// the transcriber already ran and classified this recording as
|
||||
/// silence. Treat as "transcribed, no medical content".
|
||||
/// JSON exists with `state: "silent"`. Terminal: the transcriber
|
||||
/// already ran and classified this recording as silence.
|
||||
Silent,
|
||||
/// File exists with non-empty content.
|
||||
Content(String),
|
||||
/// JSON exists with `state: "content"`.
|
||||
Content { text: String },
|
||||
}
|
||||
|
||||
impl From<Transcript> for TranscriptState {
|
||||
fn from(t: Transcript) -> Self {
|
||||
match t {
|
||||
Transcript::Silent => Self::Silent,
|
||||
Transcript::Content { text } => Self::Content { text },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TranscriptState {
|
||||
/// Build a `TranscriptState` from raw read-result bytes.
|
||||
///
|
||||
/// - `None` (file missing / read error) → [`Self::Pending`].
|
||||
/// - `Some(s)` with `s.trim().is_empty()` → [`Self::Silent`].
|
||||
/// - `Some(s)` otherwise → [`Self::Content`] (stores the full,
|
||||
/// untrimmed content — downstream joiners may want the original
|
||||
/// whitespace around the text).
|
||||
pub fn from_raw(raw: Option<&str>) -> Self {
|
||||
match raw {
|
||||
/// Build a `TranscriptState` from a parsed [`RecordingMeta`] (or its
|
||||
/// absence). `None` (file missing or malformed) becomes
|
||||
/// [`Self::Pending`].
|
||||
pub fn from_meta(meta: Option<&RecordingMeta>) -> Self {
|
||||
match meta {
|
||||
None => Self::Pending,
|
||||
Some(s) if s.trim().is_empty() => Self::Silent,
|
||||
Some(s) => Self::Content(s.to_owned()),
|
||||
Some(m) => m.transcript.clone().into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +113,9 @@ impl TranscriptState {
|
||||
!matches!(self, Self::Pending)
|
||||
}
|
||||
|
||||
/// True when there is no file yet and the transcriber still owes
|
||||
/// us a result. Use this for "should I enqueue transcription?" or
|
||||
/// "is a oneliner worth deferring?" gates.
|
||||
/// True when there is no sidecar yet and the transcriber still
|
||||
/// owes us a result. Use this for "should I enqueue transcription?"
|
||||
/// or "is a oneliner worth deferring?" gates.
|
||||
pub fn is_pending(&self) -> bool {
|
||||
matches!(self, Self::Pending)
|
||||
}
|
||||
@@ -73,7 +125,7 @@ impl TranscriptState {
|
||||
/// `match` directly.
|
||||
pub fn as_content(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Content(s) => Some(s.as_str()),
|
||||
Self::Content { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -84,38 +136,35 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_file_is_pending() {
|
||||
assert_eq!(TranscriptState::from_raw(None), TranscriptState::Pending);
|
||||
fn missing_meta_is_pending() {
|
||||
assert_eq!(TranscriptState::from_meta(None), TranscriptState::Pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string_is_silent() {
|
||||
assert_eq!(TranscriptState::from_raw(Some("")), TranscriptState::Silent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_is_silent() {
|
||||
fn silent_meta_maps_to_silent_state() {
|
||||
let meta = RecordingMeta {
|
||||
transcript: Transcript::Silent,
|
||||
duration_seconds: Some(7),
|
||||
};
|
||||
assert_eq!(
|
||||
TranscriptState::from_raw(Some(" \n\t \n")),
|
||||
TranscriptState::from_meta(Some(&meta)),
|
||||
TranscriptState::Silent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_content() {
|
||||
fn content_meta_maps_to_content_state() {
|
||||
let meta = RecordingMeta {
|
||||
transcript: Transcript::Content {
|
||||
text: "Hallo".into(),
|
||||
},
|
||||
duration_seconds: None,
|
||||
};
|
||||
assert_eq!(
|
||||
TranscriptState::from_raw(Some("Hallo")),
|
||||
TranscriptState::Content("Hallo".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_with_trailing_whitespace_preserved() {
|
||||
// Downstream join logic may rely on the original bytes;
|
||||
// from_raw keeps them verbatim.
|
||||
assert_eq!(
|
||||
TranscriptState::from_raw(Some("Hallo Welt.\n")),
|
||||
TranscriptState::Content("Hallo Welt.\n".to_owned())
|
||||
TranscriptState::from_meta(Some(&meta)),
|
||||
TranscriptState::Content {
|
||||
text: "Hallo".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,13 +172,56 @@ mod tests {
|
||||
fn has_file_distinguishes_pending() {
|
||||
assert!(!TranscriptState::Pending.has_file());
|
||||
assert!(TranscriptState::Silent.has_file());
|
||||
assert!(TranscriptState::Content("x".into()).has_file());
|
||||
assert!(TranscriptState::Content { text: "x".into() }.has_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn as_content_only_for_content() {
|
||||
assert_eq!(TranscriptState::Pending.as_content(), None);
|
||||
assert_eq!(TranscriptState::Silent.as_content(), None);
|
||||
assert_eq!(TranscriptState::Content("x".into()).as_content(), Some("x"));
|
||||
assert_eq!(
|
||||
TranscriptState::Content { text: "x".into() }.as_content(),
|
||||
Some("x")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_silent_serializes_with_tag() {
|
||||
let json = serde_json::to_string(&Transcript::Silent).unwrap();
|
||||
assert_eq!(json, r#"{"state":"silent"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_content_serializes_with_tag_and_text() {
|
||||
let json = serde_json::to_string(&Transcript::Content {
|
||||
text: "Hallo".into(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(json, r#"{"state":"content","text":"Hallo"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_meta_roundtrip_with_duration() {
|
||||
let meta = RecordingMeta {
|
||||
transcript: Transcript::Content {
|
||||
text: "Befund.".into(),
|
||||
},
|
||||
duration_seconds: Some(42),
|
||||
};
|
||||
let json = serde_json::to_string(&meta).unwrap();
|
||||
let back: RecordingMeta = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, meta);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_meta_roundtrip_without_duration() {
|
||||
let meta = RecordingMeta {
|
||||
transcript: Transcript::Silent,
|
||||
duration_seconds: None,
|
||||
};
|
||||
let json = serde_json::to_string(&meta).unwrap();
|
||||
assert!(json.contains(r#""duration_seconds":null"#));
|
||||
let back: RecordingMeta = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, meta);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user