Skip to main content

kcode_audio_ingress/
lib.rs

1//! Durable, automatic audio transcription.
2//!
3//! [`AudioIngress`] accepts owned WAV bytes, persists them before returning,
4//! and automatically drives `kcode-audio-transcribe` in the background.
5
6#![deny(missing_docs)]
7#![forbid(unsafe_code)]
8
9use std::{
10    collections::HashMap,
11    fs,
12    path::{Path, PathBuf},
13    sync::{Arc, Mutex, Weak},
14    time::Duration,
15};
16
17use anyhow::{Context, ensure};
18use chrono::{DateTime, Duration as ChronoDuration, Utc};
19use kcode_audio_transcribe::{
20    AudioTranscriber, JobState, RECONCILIATION_MODEL, RECONCILIATION_REASONING, Step, StepState,
21    TRANSCRIPTION_MODEL, TranscriptionJob, TranscriptionStatus,
22};
23use rusqlite::{Connection, OptionalExtension, params};
24use serde::Serialize;
25use sha2::{Digest, Sha256};
26use tokio::io::AsyncWriteExt;
27use uuid::Uuid;
28
29const INITIAL_MIGRATION: &str = include_str!("../migrations/001_initial.sql");
30const RELEASE_DEFERRED_INGRESS_MIGRATION: &str =
31    include_str!("../migrations/002_release_deferred_ingress.sql");
32const TRANSCRIPTION_STATUS_MIGRATION: &str =
33    include_str!("../migrations/003_transcription_status.sql");
34const RETRY_ROUNDED_WAV_INTERVALS_MIGRATION: &str =
35    include_str!("../migrations/004_retry_rounded_wav_intervals.sql");
36const UNIFIED_INGRESS_QUEUE_MIGRATION: &str =
37    include_str!("../migrations/005_unified_ingress_queue.sql");
38const STANDALONE_LIBRARY_MIGRATION: &str = include_str!("../migrations/006_standalone_library.sql");
39
40const DATABASE_FILENAME: &str = "state.sqlite3";
41const ORIGINALS_DIRECTORY: &str = "originals";
42const FAILURE_LIMIT: i64 = 5;
43const RETRY_DELAY_SECONDS: i64 = 15;
44
45const FRESH_SCHEMA: &str = r#"
46CREATE TABLE audio_recordings (
47    id TEXT PRIMARY KEY NOT NULL,
48    sha256 TEXT NOT NULL UNIQUE CHECK(length(sha256) = 64),
49    original_filename TEXT NOT NULL,
50    content_type TEXT NOT NULL,
51    size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0),
52    source_created_at TEXT NOT NULL,
53    received_at TEXT NOT NULL,
54    updated_at TEXT NOT NULL,
55    original_relative_path TEXT NOT NULL,
56    status TEXT NOT NULL CHECK(status IN (
57        'uploaded', 'chunking', 'transcribing', 'reconciling',
58        'ready_for_ingress', 'ingressing', 'ingress_failed', 'complete', 'failed'
59    )),
60    gemini_model TEXT NOT NULL,
61    reconciliation_model TEXT NOT NULL,
62    reconciliation_reasoning TEXT NOT NULL,
63    final_transcript TEXT,
64    attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count >= 0),
65    next_attempt_at TEXT,
66    last_error TEXT,
67    transcription_status_json TEXT,
68    failure_retryable INTEGER NOT NULL DEFAULT 1
69        CHECK(failure_retryable IN (0, 1))
70);
71
72CREATE INDEX audio_recordings_work_queue
73ON audio_recordings(status, next_attempt_at, received_at);
74
75PRAGMA user_version = 6;
76"#;
77
78/// Owned audio and its source metadata.
79#[derive(Clone, Debug)]
80pub struct AudioInput {
81    /// Complete WAV bytes.
82    pub bytes: Vec<u8>,
83    /// Instant at which recording began.
84    pub recorded_at: DateTime<Utc>,
85    /// Original leaf filename, when known.
86    pub original_filename: Option<String>,
87}
88
89/// Result of durably submitting audio.
90#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
91pub struct Submission {
92    /// Stable recording identifier.
93    pub recording_id: Uuid,
94    /// Whether the same audio bytes were already known.
95    pub deduplicated: bool,
96}
97
98/// Complete current library state.
99#[derive(Clone, Debug, Serialize)]
100pub struct Status {
101    /// Recordings ordered by recording time, newest first.
102    pub recordings: Vec<RecordingStatus>,
103}
104
105/// Current state and immutable identity of one recording.
106#[derive(Clone, Debug, Serialize)]
107pub struct RecordingStatus {
108    /// Stable recording identifier.
109    pub id: Uuid,
110    /// Lowercase SHA-256 digest of the original bytes.
111    pub sha256: String,
112    /// Sanitized original filename.
113    pub original_filename: String,
114    /// Original byte length.
115    pub size_bytes: u64,
116    /// Instant at which recording began.
117    pub recorded_at: DateTime<Utc>,
118    /// Instant at which AudioIngress accepted the recording.
119    pub received_at: DateTime<Utc>,
120    /// Gemini transcription model attribution.
121    pub transcription_model: String,
122    /// Codex reconciliation model attribution.
123    pub reconciliation_model: String,
124    /// Codex reconciliation reasoning attribution.
125    pub reconciliation_reasoning: String,
126    /// Current processing state.
127    pub state: RecordingState,
128}
129
130/// Automatic processing state of one recording.
131#[derive(Clone, Debug, Serialize)]
132#[serde(tag = "kind", rename_all = "snake_case")]
133pub enum RecordingState {
134    /// Persisted and waiting for automatic processing.
135    Queued,
136    /// An in-memory transcription attempt is active.
137    Processing {
138        /// One-based full-job attempt number.
139        attempt: u8,
140        /// Current dependency status. Its transcript is always omitted.
141        progress: TranscriptionStatus,
142    },
143    /// The canonical transcript is durable.
144    Complete {
145        /// Canonical reconciled Markdown.
146        transcript: String,
147    },
148    /// Automatic processing stopped.
149    Failed {
150        /// Attempts used in the current manual-attempt budget.
151        attempts: u8,
152        /// Concise diagnostic.
153        error: String,
154        /// Whether another attempt can reasonably succeed.
155        retryable: bool,
156    },
157}
158
159/// Stable library error category.
160#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161pub enum ErrorKind {
162    /// Supplied data is invalid.
163    InvalidInput,
164    /// The requested recording does not exist.
165    NotFound,
166    /// The requested transition is not valid in the current state.
167    Conflict,
168    /// Persistence or internal processing failed unexpectedly.
169    Internal,
170}
171
172/// Error returned by the minimal AudioIngress API.
173#[derive(Debug)]
174pub struct Error {
175    kind: ErrorKind,
176    message: String,
177}
178
179impl Error {
180    fn invalid(message: impl Into<String>) -> Self {
181        Self {
182            kind: ErrorKind::InvalidInput,
183            message: message.into(),
184        }
185    }
186
187    fn not_found() -> Self {
188        Self {
189            kind: ErrorKind::NotFound,
190            message: "Audio recording not found.".into(),
191        }
192    }
193
194    fn conflict(message: impl Into<String>) -> Self {
195        Self {
196            kind: ErrorKind::Conflict,
197            message: message.into(),
198        }
199    }
200
201    fn internal(error: impl std::fmt::Display) -> Self {
202        tracing::error!(%error, "AudioIngress operation failed");
203        Self {
204            kind: ErrorKind::Internal,
205            message: "An unexpected AudioIngress error occurred.".into(),
206        }
207    }
208
209    /// Returns the stable error category.
210    pub fn kind(&self) -> ErrorKind {
211        self.kind
212    }
213}
214
215impl std::fmt::Display for Error {
216    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        formatter.write_str(&self.message)
218    }
219}
220
221impl std::error::Error for Error {}
222
223struct TemporaryUpload(PathBuf);
224
225impl Drop for TemporaryUpload {
226    fn drop(&mut self) {
227        let _ = fs::remove_file(&self.0);
228    }
229}
230
231struct Inner {
232    root: PathBuf,
233    db: Mutex<Connection>,
234    transcriber: AudioTranscriber,
235    jobs: Mutex<HashMap<Uuid, TranscriptionJob>>,
236}
237
238/// Cloneable handle to durable, automatically processed audio.
239#[derive(Clone)]
240pub struct AudioIngress {
241    inner: Arc<Inner>,
242}
243
244impl AudioIngress {
245    /// Opens the owned persistence root and starts automatic processing.
246    pub async fn open(
247        persistence_root: impl AsRef<Path>,
248        transcriber: AudioTranscriber,
249    ) -> Result<Self, Error> {
250        let root = persistence_root.as_ref().to_path_buf();
251        ensure_private_directory(&root).map_err(Error::internal)?;
252        ensure_private_directory(&root.join(ORIGINALS_DIRECTORY)).map_err(Error::internal)?;
253        let connection = Connection::open(root.join(DATABASE_FILENAME)).map_err(Error::internal)?;
254        connection
255            .execute_batch(
256                "PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;",
257            )
258            .map_err(Error::internal)?;
259        apply_migrations(&connection).map_err(Error::internal)?;
260        recover_interrupted_attempts(&connection).map_err(Error::internal)?;
261
262        let inner = Arc::new(Inner {
263            root,
264            db: Mutex::new(connection),
265            transcriber,
266            jobs: Mutex::new(HashMap::new()),
267        });
268        tokio::spawn(worker_loop(Arc::downgrade(&inner)));
269        Ok(Self { inner })
270    }
271
272    /// Durably accepts complete WAV bytes and schedules automatic processing.
273    pub async fn submit(&self, input: AudioInput) -> Result<Submission, Error> {
274        if input.bytes.is_empty() {
275            return Err(Error::invalid("Audio bytes must not be empty."));
276        }
277        let size_bytes = i64::try_from(input.bytes.len())
278            .map_err(|_| Error::invalid("Audio is too large for this platform."))?;
279        let sha256 = format!("{:x}", Sha256::digest(&input.bytes));
280        if let Some(id) = self.recording_id_by_sha(&sha256)? {
281            return Ok(Submission {
282                recording_id: id,
283                deduplicated: true,
284            });
285        }
286
287        let upload_id = Uuid::new_v4();
288        let temporary = TemporaryUpload(self.inner.root.join(format!(".upload-{upload_id}.tmp")));
289        let mut file = tokio::fs::OpenOptions::new()
290            .write(true)
291            .create_new(true)
292            .open(&temporary.0)
293            .await
294            .map_err(Error::internal)?;
295        set_private_file(&temporary.0).map_err(Error::internal)?;
296        file.write_all(&input.bytes)
297            .await
298            .map_err(Error::internal)?;
299        file.sync_all().await.map_err(Error::internal)?;
300        drop(file);
301
302        let relative_path = format!("{ORIGINALS_DIRECTORY}/{sha256}.wav");
303        let final_path = self.inner.root.join(&relative_path);
304        if final_path.exists() {
305            tokio::fs::remove_file(&temporary.0)
306                .await
307                .map_err(Error::internal)?;
308        } else {
309            tokio::fs::rename(&temporary.0, &final_path)
310                .await
311                .map_err(Error::internal)?;
312        }
313        set_private_file(&final_path).map_err(Error::internal)?;
314        sync_file(&final_path).map_err(Error::internal)?;
315        sync_directory(final_path.parent().unwrap_or(&self.inner.root)).map_err(Error::internal)?;
316        sync_directory(&self.inner.root).map_err(Error::internal)?;
317
318        let id = Uuid::new_v4();
319        let now = Utc::now().to_rfc3339();
320        let filename = safe_filename(input.original_filename.as_deref());
321        let insert = {
322            let db = self.inner.db.lock().map_err(Error::internal)?;
323            db.execute(
324                "INSERT INTO audio_recordings(
325                    id,sha256,original_filename,content_type,size_bytes,
326                    source_created_at,received_at,updated_at,original_relative_path,
327                    status,gemini_model,reconciliation_model,reconciliation_reasoning
328                 ) VALUES(?1,?2,?3,'audio/wav',?4,?5,?6,?6,?7,'uploaded',?8,?9,?10)",
329                params![
330                    id.to_string(),
331                    sha256,
332                    filename,
333                    size_bytes,
334                    input.recorded_at.to_rfc3339(),
335                    now,
336                    relative_path,
337                    TRANSCRIPTION_MODEL,
338                    RECONCILIATION_MODEL,
339                    RECONCILIATION_REASONING,
340                ],
341            )
342        };
343        if let Err(error) = insert {
344            if let Some(existing) = self.recording_id_by_sha(&sha256)? {
345                return Ok(Submission {
346                    recording_id: existing,
347                    deduplicated: true,
348                });
349            }
350            return Err(Error::internal(error));
351        }
352        tracing::info!(recording_id=%id, %sha256, bytes=size_bytes, "Durably accepted audio");
353        Ok(Submission {
354            recording_id: id,
355            deduplicated: false,
356        })
357    }
358
359    /// Returns all current recording states in one snapshot.
360    pub fn status(&self) -> Result<Status, Error> {
361        let db = self.inner.db.lock().map_err(Error::internal)?;
362        let mut statement = db
363            .prepare(
364                "SELECT id,sha256,original_filename,size_bytes,source_created_at,received_at,
365                        status,gemini_model,reconciliation_model,reconciliation_reasoning,
366                        attempt_count,last_error,failure_retryable,transcription_status_json,
367                        final_transcript
368                 FROM audio_recordings
369                 ORDER BY datetime(source_created_at) DESC,datetime(received_at) DESC,id DESC",
370            )
371            .map_err(Error::internal)?;
372        let recordings = statement
373            .query_map([], row_recording_status)
374            .map_err(Error::internal)?
375            .collect::<Result<Vec<_>, _>>()
376            .map_err(Error::internal)?;
377        Ok(Status { recordings })
378    }
379
380    /// Gives a failed recording a fresh five-attempt processing budget.
381    pub fn retry(&self, recording_id: Uuid) -> Result<(), Error> {
382        let db = self.inner.db.lock().map_err(Error::internal)?;
383        let changed = db
384            .execute(
385                "UPDATE audio_recordings
386                 SET status='uploaded',attempt_count=0,next_attempt_at=NULL,last_error=NULL,
387                     transcription_status_json=NULL,failure_retryable=1,updated_at=?1
388                 WHERE id=?2 AND status='failed'",
389                params![Utc::now().to_rfc3339(), recording_id.to_string()],
390            )
391            .map_err(Error::internal)?;
392        if changed == 1 {
393            return Ok(());
394        }
395        let exists = db
396            .query_row(
397                "SELECT 1 FROM audio_recordings WHERE id=?1",
398                [recording_id.to_string()],
399                |row| row.get::<_, i64>(0),
400            )
401            .optional()
402            .map_err(Error::internal)?
403            .is_some();
404        if exists {
405            Err(Error::conflict("Only a failed recording can be retried."))
406        } else {
407            Err(Error::not_found())
408        }
409    }
410
411    fn recording_id_by_sha(&self, sha256: &str) -> Result<Option<Uuid>, Error> {
412        let db = self.inner.db.lock().map_err(Error::internal)?;
413        db.query_row(
414            "SELECT id FROM audio_recordings WHERE sha256=?1",
415            [sha256],
416            |row| row.get::<_, String>(0),
417        )
418        .optional()
419        .map_err(Error::internal)?
420        .map(|id| Uuid::parse_str(&id).map_err(Error::internal))
421        .transpose()
422    }
423}
424
425fn row_recording_status(row: &rusqlite::Row<'_>) -> rusqlite::Result<RecordingStatus> {
426    let id: String = row.get(0)?;
427    let recorded_at: String = row.get(4)?;
428    let received_at: String = row.get(5)?;
429    let durable_status: String = row.get(6)?;
430    let attempts: i64 = row.get(10)?;
431    let last_error: Option<String> = row.get(11)?;
432    let retryable: i64 = row.get(12)?;
433    let progress_json: Option<String> = row.get(13)?;
434    let transcript: Option<String> = row.get(14)?;
435    let parse_time = |index, value: &str| {
436        DateTime::parse_from_rfc3339(value)
437            .map(|value| value.with_timezone(&Utc))
438            .map_err(|error| {
439                rusqlite::Error::FromSqlConversionFailure(
440                    index,
441                    rusqlite::types::Type::Text,
442                    Box::new(error),
443                )
444            })
445    };
446    let state = match durable_status.as_str() {
447        "uploaded" => RecordingState::Queued,
448        "chunking" | "transcribing" | "reconciling" => {
449            let progress = progress_json
450                .as_deref()
451                .map(serde_json::from_str)
452                .transpose()
453                .map_err(|error| {
454                    rusqlite::Error::FromSqlConversionFailure(
455                        13,
456                        rusqlite::types::Type::Text,
457                        Box::new(error),
458                    )
459                })?
460                .unwrap_or_else(initial_progress);
461            RecordingState::Processing {
462                attempt: attempts.clamp(0, i64::from(u8::MAX)) as u8,
463                progress: without_transcript(progress),
464            }
465        }
466        "ready_for_ingress" | "ingressing" | "ingress_failed" | "complete" => {
467            RecordingState::Complete {
468                transcript: transcript.unwrap_or_default(),
469            }
470        }
471        "failed" => RecordingState::Failed {
472            attempts: attempts.clamp(0, i64::from(u8::MAX)) as u8,
473            error: last_error.unwrap_or_else(|| "Audio processing failed.".into()),
474            retryable: retryable != 0,
475        },
476        other => {
477            return Err(rusqlite::Error::FromSqlConversionFailure(
478                6,
479                rusqlite::types::Type::Text,
480                format!("unknown audio status {other:?}").into(),
481            ));
482        }
483    };
484    Ok(RecordingStatus {
485        id: Uuid::parse_str(&id).map_err(|error| {
486            rusqlite::Error::FromSqlConversionFailure(
487                0,
488                rusqlite::types::Type::Text,
489                Box::new(error),
490            )
491        })?,
492        sha256: row.get(1)?,
493        original_filename: row.get(2)?,
494        size_bytes: u64::try_from(row.get::<_, i64>(3)?).map_err(|error| {
495            rusqlite::Error::FromSqlConversionFailure(
496                3,
497                rusqlite::types::Type::Integer,
498                Box::new(error),
499            )
500        })?,
501        recorded_at: parse_time(4, &recorded_at)?,
502        received_at: parse_time(5, &received_at)?,
503        transcription_model: row.get(7)?,
504        reconciliation_model: row.get(8)?,
505        reconciliation_reasoning: row.get(9)?,
506        state,
507    })
508}
509
510async fn worker_loop(inner: Weak<Inner>) {
511    loop {
512        let Some(inner) = inner.upgrade() else {
513            return;
514        };
515        let worked = match process_next_recording(&inner).await {
516            Ok(worked) => worked,
517            Err(error) => {
518                tracing::error!(error=%error, "AudioIngress worker iteration failed");
519                false
520            }
521        };
522        drop(inner);
523        tokio::time::sleep(if worked {
524            Duration::from_millis(100)
525        } else {
526            Duration::from_secs(5)
527        })
528        .await;
529    }
530}
531
532#[derive(Debug)]
533struct WorkRecording {
534    id: Uuid,
535    original_relative_path: String,
536    attempt_count: i64,
537}
538
539async fn process_next_recording(inner: &Inner) -> anyhow::Result<bool> {
540    let recording = {
541        let db = inner
542            .db
543            .lock()
544            .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
545        fetch_work_recording(&db)?
546    };
547    let Some(recording) = recording else {
548        return Ok(false);
549    };
550    poll_transcription(inner, recording).await?;
551    Ok(true)
552}
553
554fn fetch_work_recording(db: &Connection) -> anyhow::Result<Option<WorkRecording>> {
555    db.query_row(
556        "SELECT id,original_relative_path,attempt_count
557         FROM audio_recordings
558         WHERE status IN ('uploaded','chunking','transcribing','reconciling')
559           AND (next_attempt_at IS NULL OR datetime(next_attempt_at)<=datetime('now'))
560         ORDER BY datetime(received_at),id
561         LIMIT 1",
562        [],
563        |row| {
564            let id: String = row.get(0)?;
565            Ok((id, row.get(1)?, row.get(2)?))
566        },
567    )
568    .optional()?
569    .map(|(id, original_relative_path, attempt_count)| {
570        Ok(WorkRecording {
571            id: Uuid::parse_str(&id)?,
572            original_relative_path,
573            attempt_count,
574        })
575    })
576    .transpose()
577}
578
579async fn poll_transcription(inner: &Inner, mut recording: WorkRecording) -> anyhow::Result<()> {
580    let existing = inner
581        .jobs
582        .lock()
583        .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
584        .get(&recording.id)
585        .cloned();
586    let job = if let Some(job) = existing {
587        job
588    } else {
589        if recording.attempt_count >= FAILURE_LIMIT {
590            mark_failed(
591                inner,
592                recording.id,
593                recording.attempt_count,
594                true,
595                "Audio transcription exhausted its five automatic attempts.",
596                None,
597            )?;
598            return Ok(());
599        }
600        let source = inner.root.join(&recording.original_relative_path);
601        let audio = tokio::fs::read(&source)
602            .await
603            .with_context(|| format!("reading retained audio {}", source.display()))?;
604        recording.attempt_count += 1;
605        {
606            let db = inner
607                .db
608                .lock()
609                .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
610            db.execute(
611                "UPDATE audio_recordings
612                 SET status='chunking',attempt_count=?1,next_attempt_at=NULL,last_error=NULL,
613                     failure_retryable=1,updated_at=?2
614                 WHERE id=?3",
615                params![
616                    recording.attempt_count,
617                    Utc::now().to_rfc3339(),
618                    recording.id.to_string()
619                ],
620            )?;
621        }
622        let job = inner.transcriber.transcribe(audio);
623        inner
624            .jobs
625            .lock()
626            .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
627            .insert(recording.id, job.clone());
628        job
629    };
630
631    let snapshot = job.status();
632    persist_progress(inner, recording.id, &snapshot)?;
633    match snapshot.state {
634        JobState::Queued | JobState::Running => Ok(()),
635        JobState::Completed => {
636            remove_job(inner, recording.id)?;
637            let transcript = snapshot
638                .transcript
639                .clone()
640                .context("completed transcription omitted its transcript")?;
641            ensure!(
642                !transcript.trim().is_empty(),
643                "completed transcription is empty"
644            );
645            let progress = serde_json::to_string(&without_transcript(snapshot))?;
646            let db = inner
647                .db
648                .lock()
649                .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
650            db.execute(
651                "UPDATE audio_recordings
652                 SET status='ready_for_ingress',final_transcript=?1,
653                     transcription_status_json=?2,next_attempt_at=NULL,last_error=NULL,
654                     failure_retryable=1,updated_at=?3
655                 WHERE id=?4",
656                params![
657                    transcript.trim(),
658                    progress,
659                    Utc::now().to_rfc3339(),
660                    recording.id.to_string()
661                ],
662            )?;
663            tracing::info!(recording_id=%recording.id, "Audio transcript completed");
664            Ok(())
665        }
666        JobState::Failed => {
667            remove_job(inner, recording.id)?;
668            let error = snapshot
669                .steps
670                .iter()
671                .find(|step| step.state == StepState::Failed)
672                .and_then(|step| step.error.as_ref());
673            let message = error
674                .map(|error| error.message.clone())
675                .unwrap_or_else(|| "Audio transcription failed without detail.".into());
676            let retryable = error.is_none_or(|error| error.retryable);
677            record_attempt_failure(
678                inner,
679                recording.id,
680                recording.attempt_count,
681                retryable,
682                &message,
683                Some(snapshot),
684            )
685        }
686    }
687}
688
689fn record_attempt_failure(
690    inner: &Inner,
691    id: Uuid,
692    attempts: i64,
693    retryable: bool,
694    message: &str,
695    progress: Option<TranscriptionStatus>,
696) -> anyhow::Result<()> {
697    if !retryable || attempts >= FAILURE_LIMIT {
698        return mark_failed(inner, id, attempts, retryable, message, progress);
699    }
700    let db = inner
701        .db
702        .lock()
703        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
704    db.execute(
705        "UPDATE audio_recordings
706         SET status='uploaded',next_attempt_at=?1,last_error=?2,failure_retryable=1,
707             transcription_status_json=?3,updated_at=?4
708         WHERE id=?5",
709        params![
710            (Utc::now() + ChronoDuration::seconds(RETRY_DELAY_SECONDS)).to_rfc3339(),
711            concise(message, 2_000),
712            progress
713                .map(without_transcript)
714                .map(|progress| serde_json::to_string(&progress))
715                .transpose()?,
716            Utc::now().to_rfc3339(),
717            id.to_string()
718        ],
719    )?;
720    tracing::warn!(recording_id=%id, attempt=attempts, "Audio transcription will retry");
721    Ok(())
722}
723
724fn mark_failed(
725    inner: &Inner,
726    id: Uuid,
727    attempts: i64,
728    retryable: bool,
729    message: &str,
730    progress: Option<TranscriptionStatus>,
731) -> anyhow::Result<()> {
732    let db = inner
733        .db
734        .lock()
735        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
736    db.execute(
737        "UPDATE audio_recordings
738         SET status='failed',attempt_count=?1,next_attempt_at=NULL,last_error=?2,
739             failure_retryable=?3,transcription_status_json=?4,updated_at=?5
740         WHERE id=?6",
741        params![
742            attempts,
743            concise(message, 2_000),
744            i64::from(retryable),
745            progress
746                .map(without_transcript)
747                .map(|progress| serde_json::to_string(&progress))
748                .transpose()?,
749            Utc::now().to_rfc3339(),
750            id.to_string()
751        ],
752    )?;
753    tracing::error!(recording_id=%id, attempts, retryable, "Audio transcription stopped");
754    Ok(())
755}
756
757fn persist_progress(inner: &Inner, id: Uuid, snapshot: &TranscriptionStatus) -> anyhow::Result<()> {
758    let durable_status = transcription_stage(snapshot);
759    let serialized = serde_json::to_string(&without_transcript(snapshot.clone()))?;
760    let db = inner
761        .db
762        .lock()
763        .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
764    db.execute(
765        "UPDATE audio_recordings
766         SET status=?1,transcription_status_json=?2,updated_at=?3
767         WHERE id=?4 AND (status<>?1 OR COALESCE(transcription_status_json,'')<>?2)",
768        params![
769            durable_status,
770            serialized,
771            Utc::now().to_rfc3339(),
772            id.to_string()
773        ],
774    )?;
775    Ok(())
776}
777
778fn remove_job(inner: &Inner, id: Uuid) -> anyhow::Result<()> {
779    inner
780        .jobs
781        .lock()
782        .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
783        .remove(&id);
784    Ok(())
785}
786
787fn transcription_stage(snapshot: &TranscriptionStatus) -> &'static str {
788    let plan_complete = snapshot
789        .steps
790        .iter()
791        .any(|entry| entry.step == Step::PlanChunks && entry.state == StepState::Completed);
792    if !plan_complete {
793        return "chunking";
794    }
795    let chunks_complete = snapshot
796        .steps
797        .iter()
798        .filter(|entry| matches!(entry.step, Step::TranscribeChunk { .. }))
799        .all(|entry| entry.state == StepState::Completed);
800    if chunks_complete {
801        "reconciling"
802    } else {
803        "transcribing"
804    }
805}
806
807fn without_transcript(mut status: TranscriptionStatus) -> TranscriptionStatus {
808    status.transcript = None;
809    status
810}
811
812fn initial_progress() -> TranscriptionStatus {
813    TranscriptionStatus {
814        state: JobState::Queued,
815        steps: Vec::new(),
816        transcript: None,
817    }
818}
819
820fn recover_interrupted_attempts(connection: &Connection) -> rusqlite::Result<()> {
821    connection.execute(
822        "UPDATE audio_recordings
823         SET status=CASE WHEN attempt_count>=?1 THEN 'failed' ELSE 'uploaded' END,
824             next_attempt_at=NULL,
825             last_error=CASE WHEN attempt_count>=?1
826                 THEN 'Audio transcription stopped after its fifth attempt was interrupted.'
827                 ELSE 'Audio transcription was interrupted and will restart automatically.'
828             END,
829             failure_retryable=1,
830             updated_at=?2
831         WHERE status IN ('chunking','transcribing','reconciling')",
832        params![FAILURE_LIMIT, Utc::now().to_rfc3339()],
833    )?;
834    Ok(())
835}
836
837fn apply_migrations(connection: &Connection) -> rusqlite::Result<()> {
838    let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
839    if version == 0 {
840        let has_recordings = connection.query_row(
841            "SELECT EXISTS(
842                SELECT 1 FROM sqlite_schema
843                WHERE type='table' AND name='audio_recordings'
844             )",
845            [],
846            |row| row.get::<_, i64>(0),
847        )? == 1;
848        if !has_recordings {
849            connection.execute_batch(FRESH_SCHEMA)?;
850            return Ok(());
851        }
852    }
853    if version < 1 {
854        connection.execute_batch(INITIAL_MIGRATION)?;
855    }
856    if version < 2 {
857        connection.execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)?;
858    }
859    if version < 3 {
860        connection.execute_batch(TRANSCRIPTION_STATUS_MIGRATION)?;
861    }
862    if version < 4 {
863        connection.execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)?;
864    }
865    if version < 5 {
866        connection.execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)?;
867    }
868    if version < 6 {
869        connection.execute_batch(STANDALONE_LIBRARY_MIGRATION)?;
870    }
871    Ok(())
872}
873
874fn ensure_private_directory(path: &Path) -> anyhow::Result<()> {
875    fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
876    #[cfg(unix)]
877    {
878        use std::os::unix::fs::PermissionsExt;
879        fs::set_permissions(path, fs::Permissions::from_mode(0o700))
880            .with_context(|| format!("setting private permissions on {}", path.display()))?;
881    }
882    Ok(())
883}
884
885fn set_private_file(path: &Path) -> anyhow::Result<()> {
886    #[cfg(unix)]
887    {
888        use std::os::unix::fs::PermissionsExt;
889        fs::set_permissions(path, fs::Permissions::from_mode(0o600))
890            .with_context(|| format!("setting private permissions on {}", path.display()))?;
891    }
892    Ok(())
893}
894
895fn sync_file(path: &Path) -> anyhow::Result<()> {
896    fs::OpenOptions::new()
897        .read(true)
898        .write(true)
899        .open(path)
900        .with_context(|| format!("opening {} for sync", path.display()))?
901        .sync_all()
902        .with_context(|| format!("syncing {}", path.display()))
903}
904
905fn sync_directory(path: &Path) -> anyhow::Result<()> {
906    #[cfg(unix)]
907    fs::File::open(path)
908        .with_context(|| format!("opening directory {} for sync", path.display()))?
909        .sync_all()
910        .with_context(|| format!("syncing directory {}", path.display()))?;
911    Ok(())
912}
913
914fn safe_filename(value: Option<&str>) -> String {
915    let name = value
916        .and_then(|value| Path::new(value).file_name())
917        .and_then(|value| value.to_str())
918        .unwrap_or("audio.wav");
919    let clean = name
920        .chars()
921        .map(|character| {
922            if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
923                character
924            } else {
925                '_'
926            }
927        })
928        .take(200)
929        .collect::<String>();
930    if clean.is_empty() {
931        "audio.wav".into()
932    } else {
933        clean
934    }
935}
936
937fn concise(value: &str, limit: usize) -> String {
938    let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
939    let bounded = normalized.chars().take(limit).collect::<String>();
940    if bounded.is_empty() {
941        "Audio transcription failed without an error message.".into()
942    } else {
943        bounded
944    }
945}
946
947#[cfg(test)]
948mod tests {
949    use super::*;
950
951    fn database() -> Connection {
952        let connection = Connection::open_in_memory().unwrap();
953        apply_migrations(&connection).unwrap();
954        connection
955    }
956
957    fn insert_recording(connection: &Connection, id: Uuid, status: &str) {
958        connection
959            .execute(
960                "INSERT INTO audio_recordings(
961                id,sha256,original_filename,content_type,size_bytes,source_created_at,
962                received_at,updated_at,original_relative_path,status,gemini_model,
963                reconciliation_model,reconciliation_reasoning
964             ) VALUES(?1,?2,'note.wav','audio/wav',4,?3,?3,?3,?4,?5,?6,?7,?8)",
965                params![
966                    id.to_string(),
967                    format!("{:064x}", 1),
968                    "2026-01-01T00:00:00Z",
969                    format!("originals/{id}.wav"),
970                    status,
971                    TRANSCRIPTION_MODEL,
972                    RECONCILIATION_MODEL,
973                    RECONCILIATION_REASONING,
974                ],
975            )
976            .unwrap();
977    }
978
979    #[test]
980    fn fresh_schema_contains_only_recordings() {
981        let connection = database();
982        let tables = connection
983            .prepare(
984                "SELECT name FROM sqlite_schema
985                 WHERE type='table' AND name NOT LIKE 'sqlite_%'
986                 ORDER BY name",
987            )
988            .unwrap()
989            .query_map([], |row| row.get::<_, String>(0))
990            .unwrap()
991            .collect::<Result<Vec<_>, _>>()
992            .unwrap();
993        assert_eq!(tables, vec!["audio_recordings"]);
994    }
995
996    #[test]
997    fn version_five_databases_upgrade_without_losing_legacy_queue_data() {
998        let connection = Connection::open_in_memory().unwrap();
999        connection.execute_batch(INITIAL_MIGRATION).unwrap();
1000        connection
1001            .execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)
1002            .unwrap();
1003        connection
1004            .execute_batch(TRANSCRIPTION_STATUS_MIGRATION)
1005            .unwrap();
1006        connection
1007            .execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)
1008            .unwrap();
1009        connection
1010            .execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)
1011            .unwrap();
1012        let id = Uuid::new_v4();
1013        insert_recording(&connection, id, "failed");
1014
1015        apply_migrations(&connection).unwrap();
1016
1017        let version: i64 = connection
1018            .query_row("PRAGMA user_version", [], |row| row.get(0))
1019            .unwrap();
1020        let retryable: i64 = connection
1021            .query_row(
1022                "SELECT failure_retryable FROM audio_recordings WHERE id=?1",
1023                [id.to_string()],
1024                |row| row.get(0),
1025            )
1026            .unwrap();
1027        let legacy_queue_exists: i64 = connection
1028            .query_row(
1029                "SELECT EXISTS(
1030                    SELECT 1 FROM sqlite_schema
1031                    WHERE type='table' AND name='audio_ingress_pieces'
1032                 )",
1033                [],
1034                |row| row.get(0),
1035            )
1036            .unwrap();
1037        assert_eq!(version, 6);
1038        assert_eq!(retryable, 1);
1039        assert_eq!(legacy_queue_exists, 1);
1040    }
1041
1042    #[test]
1043    fn status_exposes_one_complete_transcript() {
1044        let connection = database();
1045        let id = Uuid::new_v4();
1046        insert_recording(&connection, id, "ready_for_ingress");
1047        connection
1048            .execute(
1049                "UPDATE audio_recordings SET final_transcript='hello' WHERE id=?1",
1050                [id.to_string()],
1051            )
1052            .unwrap();
1053        let status = connection
1054            .query_row(
1055                "SELECT id,sha256,original_filename,size_bytes,source_created_at,received_at,
1056                        status,gemini_model,reconciliation_model,reconciliation_reasoning,
1057                        attempt_count,last_error,failure_retryable,transcription_status_json,
1058                        final_transcript
1059                 FROM audio_recordings WHERE id=?1",
1060                [id.to_string()],
1061                row_recording_status,
1062            )
1063            .unwrap();
1064        assert!(matches!(
1065            status.state,
1066            RecordingState::Complete { ref transcript } if transcript == "hello"
1067        ));
1068    }
1069
1070    #[test]
1071    fn interrupted_attempts_consume_the_fixed_budget() {
1072        let connection = database();
1073        let id = Uuid::new_v4();
1074        insert_recording(&connection, id, "transcribing");
1075        connection
1076            .execute(
1077                "UPDATE audio_recordings SET attempt_count=5 WHERE id=?1",
1078                [id.to_string()],
1079            )
1080            .unwrap();
1081        recover_interrupted_attempts(&connection).unwrap();
1082        let state: String = connection
1083            .query_row(
1084                "SELECT status FROM audio_recordings WHERE id=?1",
1085                [id.to_string()],
1086                |row| row.get(0),
1087            )
1088            .unwrap();
1089        assert_eq!(state, "failed");
1090    }
1091
1092    #[test]
1093    fn filenames_cannot_escape_the_persistence_root() {
1094        assert_eq!(safe_filename(Some("../../secret.wav")), "secret.wav");
1095        assert_eq!(safe_filename(Some("meeting note.wav")), "meeting_note.wav");
1096        assert_eq!(safe_filename(None), "audio.wav");
1097    }
1098}