Files
doctate/server/src/paths.rs
T
Brummel 427a28ac6b Add loudness data to recording metadata
This commit introduces the `Loudness` struct to store replay-gain data
(mean, max, and calculated gain in dB) derived from `ffmpeg`'s
`volumedetect`.

The `RecordingMeta` struct is updated to include an optional `loudness`
field. This allows for consistent playback volume across recordings with
varying microphone levels by applying gain in the browser.

Additionally, new `server/src/loudness.rs` and
`server/src/transcribe/ffmpeg.rs` modules are added. The former defines
constants for replay-gain targets and logic to compute gain, while the
latter handles audio analysis using `ffmpeg` to extract loudness
metrics.

The `#[serde(default)]` attribute on the `loudness` field in
`RecordingMeta` ensures backward compatibility with older metadata files
that do not contain this field. Tests are included to verify round-trip
serialization and parsing of older formats.
2026-04-27 16:11:02 +02:00

339 lines
13 KiB
Rust

use std::path::{Path, PathBuf};
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>/`.
/// Use everywhere instead of inlining `.join(slug).join(case_id)` so the
/// layout can evolve in one place.
pub fn case_dir(data_path: &Path, slug: &str, case_id: &str) -> PathBuf {
data_path.join(slug).join(case_id)
}
/// Filename of the close marker inside a case directory. A case with
/// this marker is "closed": hidden from the default listing, reversible
/// via a new upload or the explicit reopen action, and eligible for
/// lazy auto-purge after `auto_delete_days`.
pub const CLOSE_MARKER: &str = ".closed";
/// Close-marker payload. `closed_at` is an RFC3339 timestamp and the
/// authoritative input for purge eligibility (`now - closed_at > auto_delete_days`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloseMarker {
pub closed_at: String,
}
/// True iff the case directory contains a `.closed` marker file.
pub async fn is_closed(case_dir: &Path) -> bool {
tokio::fs::try_exists(case_dir.join(CLOSE_MARKER))
.await
.unwrap_or(false)
}
/// Read and parse the `.closed` marker. Returns `None` if absent or
/// malformed (treated identically — a malformed marker still means
/// "closed" via `is_closed`, but cannot participate in timestamp-based
/// grouping or purge checks).
pub async fn read_close_marker(case_dir: &Path) -> Option<CloseMarker> {
let bytes = tokio::fs::read(case_dir.join(CLOSE_MARKER)).await.ok()?;
serde_json::from_slice(&bytes).ok()
}
/// Write the `.closed` marker as JSON. Overwrites any existing marker.
pub async fn write_close_marker(case_dir: &Path, marker: &CloseMarker) -> std::io::Result<()> {
let bytes = serde_json::to_vec(marker)
.map_err(|e| std::io::Error::other(format!("serialize close marker: {e}")))?;
tokio::fs::write(case_dir.join(CLOSE_MARKER), bytes).await
}
/// Read the persisted oneliner state for a case.
///
/// Returns `(Some(state), Some(mtime))` when `oneliner.json` exists and
/// parses. Missing file → `(None, None)`. A file that exists but fails
/// to parse is treated as missing and logged — the next transcription
/// job will overwrite it with a well-formed state.
pub async fn read_oneliner_state(
case_dir: &Path,
) -> (Option<OnelinerState>, Option<OffsetDateTime>) {
let path = case_dir.join(ONELINER_FILENAME);
let bytes = match tokio::fs::read(&path).await {
Ok(b) => b,
Err(_) => return (None, None),
};
let state: OnelinerState = match serde_json::from_slice(&bytes) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"malformed oneliner.json; treating as missing"
);
return (None, None);
}
};
let mtime = tokio::fs::metadata(&path)
.await
.ok()
.and_then(|m| m.modified().ok())
.map(OffsetDateTime::from);
(Some(state), mtime)
}
/// 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<()> {
atomic_write_json(&case_dir.join(ONELINER_FILENAME), state).await
}
/// Delete `case_dir/oneliner.json` unless it persists a manual override.
///
/// Acquires the per-case oneliner lock, reads the current state, and
/// removes the file only when the on-disk state is missing, malformed,
/// or one of the LLM-derived variants (`Ready`, `Empty`, `Error`).
/// `OnelinerState::Manual` is preserved untouched — once a clinician
/// has set the oneliner by hand, it must survive every server-driven
/// artefact wipe (recording delete, admin reset). The only legitimate
/// path that may remove a manual oneliner is the user-initiated
/// `purge-closed`, which removes the entire case directory and is
/// therefore not routed through this helper.
///
/// Returns `Ok(true)` if the file was deleted (or was already absent),
/// `Ok(false)` if a manual override was preserved.
pub async fn delete_oneliner_unless_manual(
case_dir: &Path,
locks: &OnelinerLocks,
) -> std::io::Result<bool> {
// Hold the per-case lock across read+decide+delete so a concurrent
// worker cannot flip Manual <-> Ready between our check and our
// remove. Worker write paths take the same lock around their own
// re-check window, so the two paths serialize cleanly.
let _guard = locks.lock_for(case_dir).await;
let (state, _) = read_oneliner_state(case_dir).await;
if matches!(state, Some(OnelinerState::Manual { .. })) {
info!(
case_dir = %case_dir.display(),
"manual oneliner preserved across artefact wipe"
);
return Ok(false);
}
match tokio::fs::remove_file(case_dir.join(ONELINER_FILENAME)).await {
Ok(_) => Ok(true),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(true),
Err(e) => Err(e),
}
}
/// 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 the `.json` extension.
///
/// This is the single reader of that sidecar's transcript field.
/// Every "is this recording transcribed?" check should go through
/// here so the three-state semantics (Pending / Silent / Content)
/// stay consistent across the codebase.
pub async fn read_transcript_state(audio_stem_path: &Path) -> TranscriptState {
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,
loudness: None,
};
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)]
mod tests {
use super::*;
use tempfile::TempDir;
fn manual_state() -> OnelinerState {
OnelinerState::Manual {
text: "manuell-arzt".to_owned(),
set_at: "2026-04-26T10:00:00Z".to_owned(),
}
}
fn ready_state() -> OnelinerState {
OnelinerState::Ready {
text: "knee, left, pain".to_owned(),
generated_at: "2026-04-26T10:00:00Z".to_owned(),
}
}
#[tokio::test]
async fn delete_oneliner_unless_manual_keeps_manual() {
let dir = TempDir::new().unwrap();
write_oneliner_state(dir.path(), &manual_state())
.await
.unwrap();
let locks = OnelinerLocks::new();
let removed = delete_oneliner_unless_manual(dir.path(), &locks)
.await
.unwrap();
assert!(!removed, "Manual must be preserved");
let (state, _) = read_oneliner_state(dir.path()).await;
assert!(
matches!(state, Some(OnelinerState::Manual { ref text, .. }) if text == "manuell-arzt"),
"Manual state must remain identical on disk, got {state:?}"
);
}
#[tokio::test]
async fn delete_oneliner_unless_manual_removes_ready() {
let dir = TempDir::new().unwrap();
write_oneliner_state(dir.path(), &ready_state())
.await
.unwrap();
let locks = OnelinerLocks::new();
let removed = delete_oneliner_unless_manual(dir.path(), &locks)
.await
.unwrap();
assert!(removed, "Ready must be deleted");
assert!(!dir.path().join(ONELINER_FILENAME).exists());
}
#[tokio::test]
async fn delete_oneliner_unless_manual_handles_missing_file() {
let dir = TempDir::new().unwrap();
let locks = OnelinerLocks::new();
let removed = delete_oneliner_unless_manual(dir.path(), &locks)
.await
.expect("missing file must not be an error");
assert!(removed, "absent file is reported as deleted");
}
#[tokio::test]
async fn delete_oneliner_unless_manual_handles_malformed_json() {
// Malformed JSON must not lock the case in place forever — the
// file is treated like a non-Manual state and removed so the
// pipeline can re-derive a fresh oneliner.
let dir = TempDir::new().unwrap();
tokio::fs::write(dir.path().join(ONELINER_FILENAME), b"{ not json")
.await
.unwrap();
let locks = OnelinerLocks::new();
let removed = delete_oneliner_unless_manual(dir.path(), &locks)
.await
.unwrap();
assert!(removed, "malformed file must be cleared");
assert!(!dir.path().join(ONELINER_FILENAME).exists());
}
}