427a28ac6b
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.
298 lines
9.8 KiB
Rust
298 lines
9.8 KiB
Rust
pub mod analyze;
|
|
pub mod auth;
|
|
pub mod case_id;
|
|
pub mod config;
|
|
pub mod csrf;
|
|
pub mod error;
|
|
pub mod events;
|
|
pub mod gazetteer;
|
|
pub mod loudness;
|
|
pub mod magic_link;
|
|
pub mod models;
|
|
pub mod oneliner_locks;
|
|
pub mod paths;
|
|
pub mod retention;
|
|
pub mod routes;
|
|
pub mod settings;
|
|
pub mod transcribe;
|
|
pub mod validate;
|
|
pub mod web_session;
|
|
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
use axum::Router;
|
|
use axum::extract::FromRef;
|
|
use axum::http::{HeaderName, HeaderValue};
|
|
use tower_http::set_header::SetResponseHeaderLayer;
|
|
|
|
use analyze::AnalyzeSender;
|
|
use config::Config;
|
|
use gazetteer::Gazetteer;
|
|
use magic_link::MagicLinkStore;
|
|
use oneliner_locks::OnelinerLocks;
|
|
use settings::Settings;
|
|
use transcribe::TranscribeSender;
|
|
use web_session::SessionStore;
|
|
|
|
/// Live "is this worker currently processing a job?" flag.
|
|
/// Set true before each job, false after. UI uses it to distinguish a real
|
|
/// in-flight job from a stale on-disk marker (orphan input, missing
|
|
/// transcript) left over from a crash.
|
|
pub type WorkerBusy = Arc<AtomicBool>;
|
|
|
|
/// Newtype wrappers so `FromRef<AppState>` can target the two busy-flags
|
|
/// separately even though both are `Arc<AtomicBool>` underneath.
|
|
#[derive(Clone)]
|
|
pub struct AnalyzeBusy(pub WorkerBusy);
|
|
#[derive(Clone)]
|
|
pub struct TranscribeBusy(pub WorkerBusy);
|
|
/// Dedup flag for the detached oneliner-heal `tokio::spawn` fired from
|
|
/// `heal_orphans_if_idle`. The handler CAS's this to `true`; the spawned
|
|
/// task resets via [`BusyGuard`] on drop. Keeps concurrent page-loads
|
|
/// from launching parallel Ollama-call loops against the same data.
|
|
#[derive(Clone)]
|
|
pub struct OnelinerHealBusy(pub WorkerBusy);
|
|
|
|
/// RAII guard that flips a [`WorkerBusy`] true on construction and false on
|
|
/// drop — panic-safe. Shared across analyze + transcribe workers.
|
|
pub struct BusyGuard(WorkerBusy);
|
|
|
|
impl BusyGuard {
|
|
pub fn new(busy: WorkerBusy) -> Self {
|
|
busy.store(true, Ordering::Release);
|
|
Self(busy)
|
|
}
|
|
}
|
|
|
|
impl Drop for BusyGuard {
|
|
fn drop(&mut self) {
|
|
self.0.store(false, Ordering::Release);
|
|
}
|
|
}
|
|
|
|
/// Bundle of the four worker-pipeline handles (analyze + transcribe,
|
|
/// each with a busy-flag and a job sender). Extracted so handlers that
|
|
/// need the whole pipeline (self-heal, status probes) can take a single
|
|
/// `State<PipelineState>` instead of four separate `State(...)` params.
|
|
/// The individual `FromRef<AppState>` impls below remain valid, so
|
|
/// handlers that only need one sender keep their narrower signature.
|
|
#[derive(Clone)]
|
|
pub struct PipelineState {
|
|
pub analyze_busy: AnalyzeBusy,
|
|
pub analyze_tx: AnalyzeSender,
|
|
pub transcribe_busy: TranscribeBusy,
|
|
pub transcribe_tx: TranscribeSender,
|
|
pub oneliner_heal_busy: OnelinerHealBusy,
|
|
pub oneliner_locks: OnelinerLocks,
|
|
}
|
|
|
|
/// Shared application state. Clonable — all fields are cheap to clone
|
|
/// (`Arc` and `mpsc::Sender` / `broadcast::Sender`).
|
|
#[derive(Clone)]
|
|
pub struct AppState {
|
|
pub config: Arc<Config>,
|
|
pub settings: Arc<Settings>,
|
|
pub transcribe_tx: TranscribeSender,
|
|
pub analyze_tx: AnalyzeSender,
|
|
pub session_store: SessionStore,
|
|
pub magic_link_store: MagicLinkStore,
|
|
pub analyze_busy: AnalyzeBusy,
|
|
pub transcribe_busy: TranscribeBusy,
|
|
pub oneliner_heal_busy: OnelinerHealBusy,
|
|
pub oneliner_locks: OnelinerLocks,
|
|
pub events_tx: events::EventSender,
|
|
pub http_client: reqwest::Client,
|
|
pub vocab: Arc<Gazetteer>,
|
|
}
|
|
|
|
impl FromRef<AppState> for Arc<Config> {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
state.config.clone()
|
|
}
|
|
}
|
|
|
|
impl FromRef<AppState> for Arc<Settings> {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
state.settings.clone()
|
|
}
|
|
}
|
|
|
|
impl FromRef<AppState> for TranscribeSender {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
state.transcribe_tx.clone()
|
|
}
|
|
}
|
|
|
|
impl FromRef<AppState> for AnalyzeSender {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
state.analyze_tx.clone()
|
|
}
|
|
}
|
|
|
|
impl FromRef<AppState> for SessionStore {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
state.session_store.clone()
|
|
}
|
|
}
|
|
|
|
impl FromRef<AppState> for MagicLinkStore {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
state.magic_link_store.clone()
|
|
}
|
|
}
|
|
|
|
impl FromRef<AppState> for AnalyzeBusy {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
state.analyze_busy.clone()
|
|
}
|
|
}
|
|
|
|
impl FromRef<AppState> for TranscribeBusy {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
state.transcribe_busy.clone()
|
|
}
|
|
}
|
|
|
|
impl FromRef<AppState> for OnelinerHealBusy {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
state.oneliner_heal_busy.clone()
|
|
}
|
|
}
|
|
|
|
impl FromRef<AppState> for OnelinerLocks {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
state.oneliner_locks.clone()
|
|
}
|
|
}
|
|
|
|
impl FromRef<AppState> for PipelineState {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
PipelineState {
|
|
analyze_busy: state.analyze_busy.clone(),
|
|
analyze_tx: state.analyze_tx.clone(),
|
|
transcribe_busy: state.transcribe_busy.clone(),
|
|
transcribe_tx: state.transcribe_tx.clone(),
|
|
oneliner_heal_busy: state.oneliner_heal_busy.clone(),
|
|
oneliner_locks: state.oneliner_locks.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FromRef<AppState> for events::EventSender {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
state.events_tx.clone()
|
|
}
|
|
}
|
|
|
|
impl FromRef<AppState> for reqwest::Client {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
state.http_client.clone()
|
|
}
|
|
}
|
|
|
|
impl FromRef<AppState> for Arc<Gazetteer> {
|
|
fn from_ref(state: &AppState) -> Self {
|
|
state.vocab.clone()
|
|
}
|
|
}
|
|
|
|
/// Test/simple entrypoint: jobs pushed into either channel are dropped
|
|
/// because the receivers are not retained. Use [`create_router_with_state`]
|
|
/// from `main.rs` where real workers own the receivers.
|
|
pub fn create_router(config: Arc<Config>) -> Router {
|
|
create_router_and_session_store(config).0
|
|
}
|
|
|
|
/// Same as [`create_router`], but also returns a handle to the newly
|
|
/// created session store so tests can peek at `WebSession::csrf_token`
|
|
/// values without rendering and parsing HTML. The store handle is
|
|
/// shared with the router (`Arc`), so any session created via `/web/login`
|
|
/// or `/web/magic` becomes observable through it.
|
|
pub fn create_router_and_session_store(config: Arc<Config>) -> (Router, SessionStore) {
|
|
create_router_and_session_store_with_settings(config, Arc::new(Settings::default()))
|
|
}
|
|
|
|
/// Variant of [`create_router`] that takes both `Config` and `Settings`.
|
|
/// Use from tests that exercise Bucket-B paths (LLM, Whisper, Ollama URLs);
|
|
/// otherwise the [`create_router`] convenience with default Settings is enough.
|
|
pub fn create_router_with_settings(config: Arc<Config>, settings: Arc<Settings>) -> Router {
|
|
create_router_and_session_store_with_settings(config, settings).0
|
|
}
|
|
|
|
/// Variant of [`create_router_and_session_store`] that takes both `Config` and `Settings`.
|
|
pub fn create_router_and_session_store_with_settings(
|
|
config: Arc<Config>,
|
|
settings: Arc<Settings>,
|
|
) -> (Router, SessionStore) {
|
|
let (transcribe_tx, _transcribe_rx) = transcribe::channel();
|
|
let (analyze_tx, _analyze_rx) = analyze::channel();
|
|
let session_store = web_session::new_store();
|
|
let router = create_router_with_state(AppState {
|
|
config,
|
|
settings,
|
|
transcribe_tx,
|
|
analyze_tx,
|
|
session_store: session_store.clone(),
|
|
magic_link_store: magic_link::new_store(),
|
|
analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))),
|
|
transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))),
|
|
oneliner_heal_busy: OnelinerHealBusy(Arc::new(AtomicBool::new(false))),
|
|
oneliner_locks: OnelinerLocks::new(),
|
|
events_tx: events::channel(),
|
|
http_client: reqwest::Client::new(),
|
|
vocab: Arc::new(Gazetteer::empty()),
|
|
});
|
|
(router, session_store)
|
|
}
|
|
|
|
pub fn create_router_with_state(state: AppState) -> Router {
|
|
with_security_headers(routes::api_router().with_state(state))
|
|
}
|
|
|
|
/// Content-Security-Policy for all responses. `'unsafe-inline'` on
|
|
/// script-src / style-src is a pragmatic concession — the templates
|
|
/// include inline scripts (e.g. partials/time_format.js, localStorage
|
|
/// toggles) and user content is always escaped by askama before
|
|
/// rendering, so the XSS surface is small. Revisit if ever rendering
|
|
/// unescaped user input.
|
|
const CSP: &str = "default-src 'self'; \
|
|
script-src 'self' 'unsafe-inline'; \
|
|
style-src 'self' 'unsafe-inline'; \
|
|
img-src 'self' data:; \
|
|
media-src 'self'; \
|
|
object-src 'none'; \
|
|
base-uri 'self'; \
|
|
frame-ancestors 'none'; \
|
|
form-action 'self'";
|
|
const PERMISSIONS_POLICY: &str = "microphone=(), camera=(), geolocation=(), payment=()";
|
|
|
|
/// Defense-in-depth security headers applied to every response.
|
|
/// `if_not_present` respects per-route overrides (see routes/magic.rs,
|
|
/// which sets its own Referrer-Policy). HSTS is deliberately omitted
|
|
/// until TLS termination is in place; adding it prematurely on a
|
|
/// plain-HTTP deployment is irreversible (browser caches max-age).
|
|
fn with_security_headers(router: Router) -> Router {
|
|
router
|
|
.layer(SetResponseHeaderLayer::if_not_present(
|
|
HeaderName::from_static("x-content-type-options"),
|
|
HeaderValue::from_static("nosniff"),
|
|
))
|
|
.layer(SetResponseHeaderLayer::if_not_present(
|
|
HeaderName::from_static("x-frame-options"),
|
|
HeaderValue::from_static("DENY"),
|
|
))
|
|
.layer(SetResponseHeaderLayer::if_not_present(
|
|
HeaderName::from_static("referrer-policy"),
|
|
HeaderValue::from_static("no-referrer"),
|
|
))
|
|
.layer(SetResponseHeaderLayer::if_not_present(
|
|
HeaderName::from_static("content-security-policy"),
|
|
HeaderValue::from_static(CSP),
|
|
))
|
|
.layer(SetResponseHeaderLayer::if_not_present(
|
|
HeaderName::from_static("permissions-policy"),
|
|
HeaderValue::from_static(PERMISSIONS_POLICY),
|
|
))
|
|
}
|