1#![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 rusqlite::{Connection, OptionalExtension, params};
20use serde::Serialize;
21use serde_json::Value;
22use sha2::{Digest, Sha256};
23use tokio::io::AsyncWriteExt;
24pub use transcribe::{
25 AudioTranscriber, JobState, RECONCILIATION_MODEL, RECONCILIATION_REASONING, Step, StepError,
26 StepState, StepStatus, TRANSCRIPTION_MODEL, TranscriptionJob, TranscriptionStatus,
27};
28use transcribe::{ChunkPlan, ChunkTranscript, PIECE_CACHE_REVISION, PieceCache, PieceSink};
29use uuid::Uuid;
30
31mod transcribe;
32
33const INITIAL_MIGRATION: &str = include_str!("../migrations/001_initial.sql");
34const RELEASE_DEFERRED_INGRESS_MIGRATION: &str =
35 include_str!("../migrations/002_release_deferred_ingress.sql");
36const TRANSCRIPTION_STATUS_MIGRATION: &str =
37 include_str!("../migrations/003_transcription_status.sql");
38const RETRY_ROUNDED_WAV_INTERVALS_MIGRATION: &str =
39 include_str!("../migrations/004_retry_rounded_wav_intervals.sql");
40const UNIFIED_INGRESS_QUEUE_MIGRATION: &str =
41 include_str!("../migrations/005_unified_ingress_queue.sql");
42const STANDALONE_LIBRARY_MIGRATION: &str = include_str!("../migrations/006_standalone_library.sql");
43const DURABLE_TRANSCRIPT_PIECES_MIGRATION: &str =
44 include_str!("../migrations/007_durable_transcript_pieces.sql");
45const UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION: &str =
46 include_str!("../migrations/008_unique_transcription_attempts.sql");
47
48const DATABASE_FILENAME: &str = "state.sqlite3";
49const ORIGINALS_DIRECTORY: &str = "originals";
50const FAILURE_LIMIT: i64 = 5;
51const RETRY_DELAY_SECONDS: i64 = 15;
52const LATEST_SCHEMA_VERSION: i64 = 8;
53
54const FRESH_SCHEMA: &str = r#"
55CREATE TABLE audio_recordings (
56 id TEXT PRIMARY KEY NOT NULL,
57 sha256 TEXT NOT NULL UNIQUE CHECK(length(sha256) = 64),
58 original_filename TEXT NOT NULL,
59 content_type TEXT NOT NULL,
60 size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0),
61 source_created_at TEXT NOT NULL,
62 received_at TEXT NOT NULL,
63 updated_at TEXT NOT NULL,
64 original_relative_path TEXT NOT NULL,
65 status TEXT NOT NULL CHECK(status IN (
66 'uploaded', 'chunking', 'transcribing', 'reconciling',
67 'ready_for_ingress', 'ingressing', 'ingress_failed', 'complete', 'failed'
68 )),
69 gemini_model TEXT NOT NULL,
70 reconciliation_model TEXT NOT NULL,
71 reconciliation_reasoning TEXT NOT NULL,
72 final_transcript TEXT,
73 attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count >= 0),
74 next_attempt_at TEXT,
75 last_error TEXT,
76 transcription_status_json TEXT,
77 failure_retryable INTEGER NOT NULL DEFAULT 1
78 CHECK(failure_retryable IN (0, 1))
79);
80
81CREATE INDEX audio_recordings_work_queue
82ON audio_recordings(status, next_attempt_at, received_at);
83
84CREATE TABLE audio_transcript_pieces (
85 recording_id TEXT NOT NULL REFERENCES audio_recordings(id) ON DELETE CASCADE,
86 attempt_id TEXT NOT NULL CHECK(length(attempt_id) > 0),
87 cache_revision TEXT NOT NULL CHECK(length(cache_revision) > 0),
88 piece_index INTEGER NOT NULL CHECK(piece_index >= 0),
89 piece_count INTEGER NOT NULL CHECK(piece_count > 0 AND piece_index < piece_count),
90 audio_start_ms INTEGER NOT NULL CHECK(audio_start_ms >= 0),
91 audio_end_ms INTEGER NOT NULL CHECK(audio_end_ms > audio_start_ms),
92 transcript_json TEXT NOT NULL,
93 created_at TEXT NOT NULL,
94 PRIMARY KEY(recording_id, attempt_id, piece_index)
95);
96
97CREATE INDEX audio_transcript_pieces_cache_lookup
98ON audio_transcript_pieces(
99 recording_id,
100 cache_revision,
101 piece_index,
102 piece_count,
103 audio_start_ms,
104 audio_end_ms,
105 created_at
106);
107
108PRAGMA user_version = 8;
109"#;
110
111#[derive(Clone, Debug)]
113pub struct AudioInput {
114 pub bytes: Vec<u8>,
116 pub recorded_at: DateTime<Utc>,
118 pub original_filename: Option<String>,
120}
121
122#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
124pub struct Submission {
125 pub recording_id: Uuid,
127 pub deduplicated: bool,
129}
130
131#[derive(Clone, Debug, Serialize)]
133pub struct Status {
134 pub recordings: Vec<RecordingStatus>,
136}
137
138#[derive(Clone, Debug, Serialize)]
140pub struct RecordingStatus {
141 pub id: Uuid,
143 pub sha256: String,
145 pub original_filename: String,
147 pub size_bytes: u64,
149 pub recorded_at: DateTime<Utc>,
151 pub received_at: DateTime<Utc>,
153 pub transcription_model: String,
155 pub reconciliation_model: String,
157 pub reconciliation_reasoning: String,
159 pub state: RecordingState,
161}
162
163#[derive(Clone, Debug, Serialize)]
165#[serde(tag = "kind", rename_all = "snake_case")]
166pub enum RecordingState {
167 Queued,
169 Processing {
171 attempt: u8,
173 progress: TranscriptionStatus,
175 },
176 Complete {
178 transcript: String,
180 },
181 Failed {
183 attempts: u8,
185 error: String,
187 retryable: bool,
189 },
190}
191
192#[derive(Clone, Copy, Debug, Eq, PartialEq)]
194pub enum ErrorKind {
195 InvalidInput,
197 NotFound,
199 Conflict,
201 Internal,
203}
204
205#[derive(Debug)]
207pub struct Error {
208 kind: ErrorKind,
209 message: String,
210}
211
212impl Error {
213 fn invalid(message: impl Into<String>) -> Self {
214 Self {
215 kind: ErrorKind::InvalidInput,
216 message: message.into(),
217 }
218 }
219
220 fn not_found() -> Self {
221 Self {
222 kind: ErrorKind::NotFound,
223 message: "Audio recording not found.".into(),
224 }
225 }
226
227 fn conflict(message: impl Into<String>) -> Self {
228 Self {
229 kind: ErrorKind::Conflict,
230 message: message.into(),
231 }
232 }
233
234 fn internal(error: impl std::fmt::Display) -> Self {
235 tracing::error!(%error, "AudioIngress operation failed");
236 Self {
237 kind: ErrorKind::Internal,
238 message: "An unexpected AudioIngress error occurred.".into(),
239 }
240 }
241
242 pub fn kind(&self) -> ErrorKind {
244 self.kind
245 }
246}
247
248impl std::fmt::Display for Error {
249 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250 formatter.write_str(&self.message)
251 }
252}
253
254impl std::error::Error for Error {}
255
256struct TemporaryUpload(PathBuf);
257
258impl Drop for TemporaryUpload {
259 fn drop(&mut self) {
260 let _ = fs::remove_file(&self.0);
261 }
262}
263
264struct Inner {
265 root: PathBuf,
266 db: Arc<Mutex<Connection>>,
267 transcriber: AudioTranscriber,
268 jobs: Mutex<HashMap<Uuid, TranscriptionJob>>,
269}
270
271#[derive(Clone)]
273pub struct AudioIngress {
274 inner: Arc<Inner>,
275}
276
277impl AudioIngress {
278 pub async fn open(
280 persistence_root: impl AsRef<Path>,
281 transcriber: AudioTranscriber,
282 ) -> Result<Self, Error> {
283 let root = persistence_root.as_ref().to_path_buf();
284 ensure_private_directory(&root).map_err(Error::internal)?;
285 ensure_private_directory(&root.join(ORIGINALS_DIRECTORY)).map_err(Error::internal)?;
286 let connection = Connection::open(root.join(DATABASE_FILENAME)).map_err(Error::internal)?;
287 connection
288 .execute_batch(
289 "PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;",
290 )
291 .map_err(Error::internal)?;
292 apply_migrations(&connection).map_err(Error::internal)?;
293 recover_interrupted_attempts(&connection).map_err(Error::internal)?;
294
295 let inner = Arc::new(Inner {
296 root,
297 db: Arc::new(Mutex::new(connection)),
298 transcriber,
299 jobs: Mutex::new(HashMap::new()),
300 });
301 tokio::spawn(worker_loop(Arc::downgrade(&inner)));
302 Ok(Self { inner })
303 }
304
305 pub async fn submit(&self, input: AudioInput) -> Result<Submission, Error> {
307 if input.bytes.is_empty() {
308 return Err(Error::invalid("Audio bytes must not be empty."));
309 }
310 let size_bytes = i64::try_from(input.bytes.len())
311 .map_err(|_| Error::invalid("Audio is too large for this platform."))?;
312 let sha256 = format!("{:x}", Sha256::digest(&input.bytes));
313 if let Some(id) = self.recording_id_by_sha(&sha256)? {
314 return Ok(Submission {
315 recording_id: id,
316 deduplicated: true,
317 });
318 }
319
320 let upload_id = Uuid::new_v4();
321 let temporary = TemporaryUpload(self.inner.root.join(format!(".upload-{upload_id}.tmp")));
322 let mut file = tokio::fs::OpenOptions::new()
323 .write(true)
324 .create_new(true)
325 .open(&temporary.0)
326 .await
327 .map_err(Error::internal)?;
328 set_private_file(&temporary.0).map_err(Error::internal)?;
329 file.write_all(&input.bytes)
330 .await
331 .map_err(Error::internal)?;
332 file.sync_all().await.map_err(Error::internal)?;
333 drop(file);
334
335 let relative_path = format!("{ORIGINALS_DIRECTORY}/{sha256}.wav");
336 let final_path = self.inner.root.join(&relative_path);
337 if final_path.exists() {
338 tokio::fs::remove_file(&temporary.0)
339 .await
340 .map_err(Error::internal)?;
341 } else {
342 tokio::fs::rename(&temporary.0, &final_path)
343 .await
344 .map_err(Error::internal)?;
345 }
346 set_private_file(&final_path).map_err(Error::internal)?;
347 sync_file(&final_path).map_err(Error::internal)?;
348 sync_directory(final_path.parent().unwrap_or(&self.inner.root)).map_err(Error::internal)?;
349 sync_directory(&self.inner.root).map_err(Error::internal)?;
350
351 let id = Uuid::new_v4();
352 let now = Utc::now().to_rfc3339();
353 let filename = safe_filename(input.original_filename.as_deref());
354 let insert = {
355 let db = self.inner.db.lock().map_err(Error::internal)?;
356 db.execute(
357 "INSERT INTO audio_recordings(
358 id,sha256,original_filename,content_type,size_bytes,
359 source_created_at,received_at,updated_at,original_relative_path,
360 status,gemini_model,reconciliation_model,reconciliation_reasoning
361 ) VALUES(?1,?2,?3,'audio/wav',?4,?5,?6,?6,?7,'uploaded',?8,?9,?10)",
362 params![
363 id.to_string(),
364 sha256,
365 filename,
366 size_bytes,
367 input.recorded_at.to_rfc3339(),
368 now,
369 relative_path,
370 TRANSCRIPTION_MODEL,
371 RECONCILIATION_MODEL,
372 RECONCILIATION_REASONING,
373 ],
374 )
375 };
376 if let Err(error) = insert {
377 if let Some(existing) = self.recording_id_by_sha(&sha256)? {
378 return Ok(Submission {
379 recording_id: existing,
380 deduplicated: true,
381 });
382 }
383 return Err(Error::internal(error));
384 }
385 tracing::info!(recording_id=%id, %sha256, bytes=size_bytes, "Durably accepted audio");
386 Ok(Submission {
387 recording_id: id,
388 deduplicated: false,
389 })
390 }
391
392 pub fn status(&self) -> Result<Status, Error> {
394 let db = self.inner.db.lock().map_err(Error::internal)?;
395 let mut statement = db
396 .prepare(
397 "SELECT id,sha256,original_filename,size_bytes,source_created_at,received_at,
398 status,gemini_model,reconciliation_model,reconciliation_reasoning,
399 attempt_count,last_error,failure_retryable,transcription_status_json,
400 final_transcript
401 FROM audio_recordings
402 ORDER BY datetime(source_created_at) DESC,datetime(received_at) DESC,id DESC",
403 )
404 .map_err(Error::internal)?;
405 let recordings = statement
406 .query_map([], row_recording_status)
407 .map_err(Error::internal)?
408 .collect::<Result<Vec<_>, _>>()
409 .map_err(Error::internal)?;
410 Ok(Status { recordings })
411 }
412
413 pub fn retry(&self, recording_id: Uuid) -> Result<(), Error> {
415 let db = self.inner.db.lock().map_err(Error::internal)?;
416 let changed = db
417 .execute(
418 "UPDATE audio_recordings
419 SET status='uploaded',attempt_count=0,next_attempt_at=NULL,last_error=NULL,
420 transcription_status_json=NULL,failure_retryable=1,updated_at=?1
421 WHERE id=?2 AND status='failed'",
422 params![Utc::now().to_rfc3339(), recording_id.to_string()],
423 )
424 .map_err(Error::internal)?;
425 if changed == 1 {
426 return Ok(());
427 }
428 let exists = db
429 .query_row(
430 "SELECT 1 FROM audio_recordings WHERE id=?1",
431 [recording_id.to_string()],
432 |row| row.get::<_, i64>(0),
433 )
434 .optional()
435 .map_err(Error::internal)?
436 .is_some();
437 if exists {
438 Err(Error::conflict("Only a failed recording can be retried."))
439 } else {
440 Err(Error::not_found())
441 }
442 }
443
444 fn recording_id_by_sha(&self, sha256: &str) -> Result<Option<Uuid>, Error> {
445 let db = self.inner.db.lock().map_err(Error::internal)?;
446 db.query_row(
447 "SELECT id FROM audio_recordings WHERE sha256=?1",
448 [sha256],
449 |row| row.get::<_, String>(0),
450 )
451 .optional()
452 .map_err(Error::internal)?
453 .map(|id| Uuid::parse_str(&id).map_err(Error::internal))
454 .transpose()
455 }
456}
457
458fn row_recording_status(row: &rusqlite::Row<'_>) -> rusqlite::Result<RecordingStatus> {
459 let id: String = row.get(0)?;
460 let recorded_at: String = row.get(4)?;
461 let received_at: String = row.get(5)?;
462 let durable_status: String = row.get(6)?;
463 let attempts: i64 = row.get(10)?;
464 let last_error: Option<String> = row.get(11)?;
465 let retryable: i64 = row.get(12)?;
466 let progress_json: Option<String> = row.get(13)?;
467 let transcript: Option<String> = row.get(14)?;
468 let parse_time = |index, value: &str| {
469 DateTime::parse_from_rfc3339(value)
470 .map(|value| value.with_timezone(&Utc))
471 .map_err(|error| {
472 rusqlite::Error::FromSqlConversionFailure(
473 index,
474 rusqlite::types::Type::Text,
475 Box::new(error),
476 )
477 })
478 };
479 let state = match durable_status.as_str() {
480 "uploaded" => RecordingState::Queued,
481 "chunking" | "transcribing" | "reconciling" => {
482 let progress = progress_json
483 .as_deref()
484 .map(serde_json::from_str)
485 .transpose()
486 .map_err(|error| {
487 rusqlite::Error::FromSqlConversionFailure(
488 13,
489 rusqlite::types::Type::Text,
490 Box::new(error),
491 )
492 })?
493 .unwrap_or_else(initial_progress);
494 RecordingState::Processing {
495 attempt: attempts.clamp(0, i64::from(u8::MAX)) as u8,
496 progress: without_transcript(progress),
497 }
498 }
499 "ready_for_ingress" | "ingressing" | "ingress_failed" | "complete" => {
500 RecordingState::Complete {
501 transcript: transcript.unwrap_or_default(),
502 }
503 }
504 "failed" => RecordingState::Failed {
505 attempts: attempts.clamp(0, i64::from(u8::MAX)) as u8,
506 error: last_error.unwrap_or_else(|| "Audio processing failed.".into()),
507 retryable: retryable != 0,
508 },
509 other => {
510 return Err(rusqlite::Error::FromSqlConversionFailure(
511 6,
512 rusqlite::types::Type::Text,
513 format!("unknown audio status {other:?}").into(),
514 ));
515 }
516 };
517 Ok(RecordingStatus {
518 id: Uuid::parse_str(&id).map_err(|error| {
519 rusqlite::Error::FromSqlConversionFailure(
520 0,
521 rusqlite::types::Type::Text,
522 Box::new(error),
523 )
524 })?,
525 sha256: row.get(1)?,
526 original_filename: row.get(2)?,
527 size_bytes: u64::try_from(row.get::<_, i64>(3)?).map_err(|error| {
528 rusqlite::Error::FromSqlConversionFailure(
529 3,
530 rusqlite::types::Type::Integer,
531 Box::new(error),
532 )
533 })?,
534 recorded_at: parse_time(4, &recorded_at)?,
535 received_at: parse_time(5, &received_at)?,
536 transcription_model: row.get(7)?,
537 reconciliation_model: row.get(8)?,
538 reconciliation_reasoning: row.get(9)?,
539 state,
540 })
541}
542
543async fn worker_loop(inner: Weak<Inner>) {
544 loop {
545 let Some(inner) = inner.upgrade() else {
546 return;
547 };
548 let worked = match process_next_recording(&inner).await {
549 Ok(worked) => worked,
550 Err(error) => {
551 tracing::error!(error=%error, "AudioIngress worker iteration failed");
552 false
553 }
554 };
555 drop(inner);
556 tokio::time::sleep(if worked {
557 Duration::from_millis(100)
558 } else {
559 Duration::from_secs(5)
560 })
561 .await;
562 }
563}
564
565#[derive(Debug)]
566struct WorkRecording {
567 id: Uuid,
568 original_relative_path: String,
569 attempt_count: i64,
570}
571
572async fn process_next_recording(inner: &Inner) -> anyhow::Result<bool> {
573 let recording = {
574 let db = inner
575 .db
576 .lock()
577 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
578 fetch_work_recording(&db)?
579 };
580 let Some(recording) = recording else {
581 return Ok(false);
582 };
583 poll_transcription(inner, recording).await?;
584 Ok(true)
585}
586
587fn fetch_work_recording(db: &Connection) -> anyhow::Result<Option<WorkRecording>> {
588 db.query_row(
589 "SELECT id,original_relative_path,attempt_count
590 FROM audio_recordings
591 WHERE status IN ('uploaded','chunking','transcribing','reconciling')
592 AND (next_attempt_at IS NULL OR datetime(next_attempt_at)<=datetime('now'))
593 ORDER BY datetime(received_at),id
594 LIMIT 1",
595 [],
596 |row| {
597 let id: String = row.get(0)?;
598 Ok((id, row.get(1)?, row.get(2)?))
599 },
600 )
601 .optional()?
602 .map(|(id, original_relative_path, attempt_count)| {
603 Ok(WorkRecording {
604 id: Uuid::parse_str(&id)?,
605 original_relative_path,
606 attempt_count,
607 })
608 })
609 .transpose()
610}
611
612async fn poll_transcription(inner: &Inner, mut recording: WorkRecording) -> anyhow::Result<()> {
613 let existing = inner
614 .jobs
615 .lock()
616 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
617 .get(&recording.id)
618 .cloned();
619 let job = if let Some(job) = existing {
620 job
621 } else {
622 if recording.attempt_count >= FAILURE_LIMIT {
623 mark_failed(
624 inner,
625 recording.id,
626 recording.attempt_count,
627 true,
628 "Audio transcription exhausted its five automatic attempts.",
629 None,
630 )?;
631 return Ok(());
632 }
633 let source = inner.root.join(&recording.original_relative_path);
634 let audio = tokio::fs::read(&source)
635 .await
636 .with_context(|| format!("reading retained audio {}", source.display()))?;
637 recording.attempt_count += 1;
638 {
639 let db = inner
640 .db
641 .lock()
642 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
643 db.execute(
644 "UPDATE audio_recordings
645 SET status='chunking',attempt_count=?1,next_attempt_at=NULL,last_error=NULL,
646 failure_retryable=1,updated_at=?2
647 WHERE id=?3",
648 params![
649 recording.attempt_count,
650 Utc::now().to_rfc3339(),
651 recording.id.to_string()
652 ],
653 )?;
654 }
655
656 let cache_db = inner.db.clone();
657 let cache_recording_id = recording.id;
658 let piece_cache: PieceCache =
659 Arc::new(move |plan| load_cached_transcript_piece(&cache_db, cache_recording_id, plan));
660
661 let sink_db = inner.db.clone();
662 let sink_recording_id = recording.id;
663 let attempt_id = Uuid::new_v4().to_string();
664 let piece_sink: PieceSink = Arc::new(move |piece| {
665 persist_transcript_piece(&sink_db, sink_recording_id, &attempt_id, piece)
666 });
667
668 let job = inner
669 .transcriber
670 .transcribe_durably(audio, piece_cache, piece_sink);
671 inner
672 .jobs
673 .lock()
674 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
675 .insert(recording.id, job.clone());
676 job
677 };
678
679 let snapshot = job.status();
680 persist_progress(inner, recording.id, &snapshot)?;
681 match snapshot.state {
682 JobState::Queued | JobState::Running => Ok(()),
683 JobState::Completed => {
684 remove_job(inner, recording.id)?;
685 let transcript = snapshot
686 .transcript
687 .clone()
688 .context("completed transcription omitted its transcript")?;
689 ensure!(
690 !transcript.trim().is_empty(),
691 "completed transcription is empty"
692 );
693 let progress = serde_json::to_string(&without_transcript(snapshot))?;
694 let db = inner
695 .db
696 .lock()
697 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
698 db.execute(
699 "UPDATE audio_recordings
700 SET status='ready_for_ingress',final_transcript=?1,
701 transcription_status_json=?2,next_attempt_at=NULL,last_error=NULL,
702 failure_retryable=1,updated_at=?3
703 WHERE id=?4",
704 params![
705 transcript.trim(),
706 progress,
707 Utc::now().to_rfc3339(),
708 recording.id.to_string()
709 ],
710 )?;
711 tracing::info!(recording_id=%recording.id, "Audio transcript completed");
712 Ok(())
713 }
714 JobState::Failed => {
715 remove_job(inner, recording.id)?;
716 let error = snapshot
717 .steps
718 .iter()
719 .find(|step| step.state == StepState::Failed)
720 .and_then(|step| step.error.as_ref());
721 let message = error
722 .map(|error| error.message.clone())
723 .unwrap_or_else(|| "Audio transcription failed without detail.".into());
724 let retryable = error.is_none_or(|error| error.retryable);
725 record_attempt_failure(
726 inner,
727 recording.id,
728 recording.attempt_count,
729 retryable,
730 &message,
731 Some(snapshot),
732 )
733 }
734 }
735}
736
737fn record_attempt_failure(
738 inner: &Inner,
739 id: Uuid,
740 attempts: i64,
741 retryable: bool,
742 message: &str,
743 progress: Option<TranscriptionStatus>,
744) -> anyhow::Result<()> {
745 if !retryable || attempts >= FAILURE_LIMIT {
746 return mark_failed(inner, id, attempts, retryable, message, progress);
747 }
748 let db = inner
749 .db
750 .lock()
751 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
752 db.execute(
753 "UPDATE audio_recordings
754 SET status='uploaded',next_attempt_at=?1,last_error=?2,failure_retryable=1,
755 transcription_status_json=?3,updated_at=?4
756 WHERE id=?5",
757 params![
758 (Utc::now() + ChronoDuration::seconds(RETRY_DELAY_SECONDS)).to_rfc3339(),
759 concise(message, 2_000),
760 progress
761 .map(without_transcript)
762 .map(|progress| serde_json::to_string(&progress))
763 .transpose()?,
764 Utc::now().to_rfc3339(),
765 id.to_string()
766 ],
767 )?;
768 tracing::warn!(recording_id=%id, attempt=attempts, "Audio transcription will retry");
769 Ok(())
770}
771
772fn mark_failed(
773 inner: &Inner,
774 id: Uuid,
775 attempts: i64,
776 retryable: bool,
777 message: &str,
778 progress: Option<TranscriptionStatus>,
779) -> anyhow::Result<()> {
780 let db = inner
781 .db
782 .lock()
783 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
784 db.execute(
785 "UPDATE audio_recordings
786 SET status='failed',attempt_count=?1,next_attempt_at=NULL,last_error=?2,
787 failure_retryable=?3,transcription_status_json=?4,updated_at=?5
788 WHERE id=?6",
789 params![
790 attempts,
791 concise(message, 2_000),
792 i64::from(retryable),
793 progress
794 .map(without_transcript)
795 .map(|progress| serde_json::to_string(&progress))
796 .transpose()?,
797 Utc::now().to_rfc3339(),
798 id.to_string()
799 ],
800 )?;
801 tracing::error!(recording_id=%id, attempts, retryable, "Audio transcription stopped");
802 Ok(())
803}
804
805fn persist_progress(inner: &Inner, id: Uuid, snapshot: &TranscriptionStatus) -> anyhow::Result<()> {
806 let durable_status = transcription_stage(snapshot);
807 let serialized = serde_json::to_string(&without_transcript(snapshot.clone()))?;
808 let db = inner
809 .db
810 .lock()
811 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
812 db.execute(
813 "UPDATE audio_recordings
814 SET status=?1,transcription_status_json=?2,updated_at=?3
815 WHERE id=?4 AND (status<>?1 OR COALESCE(transcription_status_json,'')<>?2)",
816 params![
817 durable_status,
818 serialized,
819 Utc::now().to_rfc3339(),
820 id.to_string()
821 ],
822 )?;
823 Ok(())
824}
825
826fn load_cached_transcript_piece(
827 db: &Mutex<Connection>,
828 recording_id: Uuid,
829 plan: ChunkPlan,
830) -> anyhow::Result<Option<String>> {
831 let piece_index = i64::try_from(plan.index).context("piece index exceeds SQLite limits")?;
832 let piece_count = i64::try_from(plan.total).context("piece count exceeds SQLite limits")?;
833 let audio_start_ms =
834 i64::try_from(plan.start_ms).context("piece start exceeds SQLite limits")?;
835 let audio_end_ms = i64::try_from(plan.end_ms).context("piece end exceeds SQLite limits")?;
836 let transcript = {
837 let db = db
838 .lock()
839 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
840 db.query_row(
841 "SELECT transcript_json
842 FROM audio_transcript_pieces
843 WHERE recording_id=?1
844 AND cache_revision=?2
845 AND piece_index=?3
846 AND piece_count=?4
847 AND audio_start_ms=?5
848 AND audio_end_ms=?6
849 ORDER BY datetime(created_at) DESC,attempt_id DESC
850 LIMIT 1",
851 params![
852 recording_id.to_string(),
853 PIECE_CACHE_REVISION,
854 piece_index,
855 piece_count,
856 audio_start_ms,
857 audio_end_ms,
858 ],
859 |row| row.get::<_, String>(0),
860 )
861 .optional()?
862 };
863 if let Some(value) = transcript.as_deref() {
864 let parsed: Value =
865 serde_json::from_str(value).context("cached piece transcript is invalid JSON")?;
866 ensure!(
867 parsed.get("utterances").and_then(Value::as_array).is_some(),
868 "cached piece transcript omitted the utterances array"
869 );
870 }
871 Ok(transcript)
872}
873
874fn persist_transcript_piece(
875 db: &Mutex<Connection>,
876 recording_id: Uuid,
877 attempt_id: &str,
878 piece: &ChunkTranscript,
879) -> anyhow::Result<()> {
880 ensure!(!attempt_id.is_empty(), "piece attempt identity is empty");
881 let piece_index =
882 i64::try_from(piece.plan.index).context("piece index exceeds SQLite limits")?;
883 let piece_count =
884 i64::try_from(piece.plan.total).context("piece count exceeds SQLite limits")?;
885 let audio_start_ms =
886 i64::try_from(piece.plan.start_ms).context("piece start exceeds SQLite limits")?;
887 let audio_end_ms =
888 i64::try_from(piece.plan.end_ms).context("piece end exceeds SQLite limits")?;
889 let db = db
890 .lock()
891 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
892 db.execute(
893 "INSERT INTO audio_transcript_pieces(
894 recording_id,attempt_id,cache_revision,piece_index,piece_count,
895 audio_start_ms,audio_end_ms,transcript_json,created_at
896 ) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9)
897 ON CONFLICT(recording_id,attempt_id,piece_index) DO UPDATE SET
898 cache_revision=excluded.cache_revision,
899 piece_count=excluded.piece_count,
900 audio_start_ms=excluded.audio_start_ms,
901 audio_end_ms=excluded.audio_end_ms,
902 transcript_json=excluded.transcript_json",
903 params![
904 recording_id.to_string(),
905 attempt_id,
906 PIECE_CACHE_REVISION,
907 piece_index,
908 piece_count,
909 audio_start_ms,
910 audio_end_ms,
911 piece.transcript,
912 Utc::now().to_rfc3339(),
913 ],
914 )?;
915 Ok(())
916}
917
918fn remove_job(inner: &Inner, id: Uuid) -> anyhow::Result<()> {
919 inner
920 .jobs
921 .lock()
922 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
923 .remove(&id);
924 Ok(())
925}
926
927fn transcription_stage(snapshot: &TranscriptionStatus) -> &'static str {
928 let plan_complete = snapshot
929 .steps
930 .iter()
931 .any(|entry| entry.step == Step::PlanChunks && entry.state == StepState::Completed);
932 if !plan_complete {
933 return "chunking";
934 }
935 let chunks_complete = snapshot
936 .steps
937 .iter()
938 .filter(|entry| matches!(entry.step, Step::TranscribeChunk { .. }))
939 .all(|entry| entry.state == StepState::Completed);
940 if chunks_complete {
941 "reconciling"
942 } else {
943 "transcribing"
944 }
945}
946
947fn without_transcript(mut status: TranscriptionStatus) -> TranscriptionStatus {
948 status.transcript = None;
949 status
950}
951
952fn initial_progress() -> TranscriptionStatus {
953 TranscriptionStatus {
954 state: JobState::Queued,
955 steps: Vec::new(),
956 transcript: None,
957 }
958}
959
960fn recover_interrupted_attempts(connection: &Connection) -> rusqlite::Result<()> {
961 connection.execute(
962 "UPDATE audio_recordings
963 SET status=CASE WHEN attempt_count>=?1 THEN 'failed' ELSE 'uploaded' END,
964 next_attempt_at=NULL,
965 last_error=CASE WHEN attempt_count>=?1
966 THEN 'Audio transcription stopped after its fifth attempt was interrupted.'
967 ELSE 'Audio transcription was interrupted and will restart automatically.'
968 END,
969 failure_retryable=1,
970 updated_at=?2
971 WHERE status IN ('chunking','transcribing','reconciling')",
972 params![FAILURE_LIMIT, Utc::now().to_rfc3339()],
973 )?;
974 Ok(())
975}
976
977fn apply_migrations(connection: &Connection) -> anyhow::Result<()> {
978 let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
979 ensure!(
980 version <= LATEST_SCHEMA_VERSION,
981 "audio-ingress database schema version {version} is newer than supported version {LATEST_SCHEMA_VERSION}"
982 );
983 if version == 0 {
984 let has_recordings = connection.query_row(
985 "SELECT EXISTS(
986 SELECT 1 FROM sqlite_schema
987 WHERE type='table' AND name='audio_recordings'
988 )",
989 [],
990 |row| row.get::<_, i64>(0),
991 )? == 1;
992 if !has_recordings {
993 connection.execute_batch(FRESH_SCHEMA)?;
994 return Ok(());
995 }
996 }
997 if version < 1 {
998 connection.execute_batch(INITIAL_MIGRATION)?;
999 }
1000 if version < 2 {
1001 connection.execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)?;
1002 }
1003 if version < 3 {
1004 connection.execute_batch(TRANSCRIPTION_STATUS_MIGRATION)?;
1005 }
1006 if version < 4 {
1007 connection.execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)?;
1008 }
1009 if version < 5 {
1010 connection.execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)?;
1011 }
1012 if version < 6 {
1013 connection.execute_batch(STANDALONE_LIBRARY_MIGRATION)?;
1014 }
1015 if version < 7 {
1016 connection.execute_batch(DURABLE_TRANSCRIPT_PIECES_MIGRATION)?;
1017 }
1018 if version < 8 {
1019 connection.execute_batch(UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION)?;
1020 }
1021 Ok(())
1022}
1023
1024fn ensure_private_directory(path: &Path) -> anyhow::Result<()> {
1025 fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
1026 #[cfg(unix)]
1027 {
1028 use std::os::unix::fs::PermissionsExt;
1029 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
1030 .with_context(|| format!("setting private permissions on {}", path.display()))?;
1031 }
1032 Ok(())
1033}
1034
1035fn set_private_file(path: &Path) -> anyhow::Result<()> {
1036 #[cfg(unix)]
1037 {
1038 use std::os::unix::fs::PermissionsExt;
1039 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
1040 .with_context(|| format!("setting private permissions on {}", path.display()))?;
1041 }
1042 Ok(())
1043}
1044
1045fn sync_file(path: &Path) -> anyhow::Result<()> {
1046 fs::OpenOptions::new()
1047 .read(true)
1048 .write(true)
1049 .open(path)
1050 .with_context(|| format!("opening {} for sync", path.display()))?
1051 .sync_all()
1052 .with_context(|| format!("syncing {}", path.display()))
1053}
1054
1055fn sync_directory(path: &Path) -> anyhow::Result<()> {
1056 #[cfg(unix)]
1057 fs::File::open(path)
1058 .with_context(|| format!("opening directory {} for sync", path.display()))?
1059 .sync_all()
1060 .with_context(|| format!("syncing directory {}", path.display()))?;
1061 Ok(())
1062}
1063
1064fn safe_filename(value: Option<&str>) -> String {
1065 let name = value
1066 .and_then(|value| Path::new(value).file_name())
1067 .and_then(|value| value.to_str())
1068 .unwrap_or("audio.wav");
1069 let clean = name
1070 .chars()
1071 .map(|character| {
1072 if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
1073 character
1074 } else {
1075 '_'
1076 }
1077 })
1078 .take(200)
1079 .collect::<String>();
1080 if clean.is_empty() {
1081 "audio.wav".into()
1082 } else {
1083 clean
1084 }
1085}
1086
1087fn concise(value: &str, limit: usize) -> String {
1088 let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
1089 let bounded = normalized.chars().take(limit).collect::<String>();
1090 if bounded.is_empty() {
1091 "Audio transcription failed without an error message.".into()
1092 } else {
1093 bounded
1094 }
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099 use super::*;
1100
1101 fn database() -> Connection {
1102 let connection = Connection::open_in_memory().unwrap();
1103 apply_migrations(&connection).unwrap();
1104 connection
1105 }
1106
1107 fn insert_recording(connection: &Connection, id: Uuid, status: &str) {
1108 connection
1109 .execute(
1110 "INSERT INTO audio_recordings(
1111 id,sha256,original_filename,content_type,size_bytes,source_created_at,
1112 received_at,updated_at,original_relative_path,status,gemini_model,
1113 reconciliation_model,reconciliation_reasoning
1114 ) VALUES(?1,?2,'note.wav','audio/wav',4,?3,?3,?3,?4,?5,?6,?7,?8)",
1115 params![
1116 id.to_string(),
1117 format!("{:064x}", 1),
1118 "2026-01-01T00:00:00Z",
1119 format!("originals/{id}.wav"),
1120 status,
1121 TRANSCRIPTION_MODEL,
1122 RECONCILIATION_MODEL,
1123 RECONCILIATION_REASONING,
1124 ],
1125 )
1126 .unwrap();
1127 }
1128
1129 fn sample_piece() -> ChunkTranscript {
1130 ChunkTranscript {
1131 plan: ChunkPlan {
1132 index: 0,
1133 total: 2,
1134 start_ms: 0,
1135 end_ms: 120_000,
1136 },
1137 transcript: r#"{"utterances":[{"original_text":"hello"}]}"#.into(),
1138 }
1139 }
1140
1141 #[test]
1142 fn fresh_schema_contains_recordings_and_durable_pieces() {
1143 let connection = database();
1144 let tables = connection
1145 .prepare(
1146 "SELECT name FROM sqlite_schema
1147 WHERE type='table' AND name NOT LIKE 'sqlite_%'
1148 ORDER BY name",
1149 )
1150 .unwrap()
1151 .query_map([], |row| row.get::<_, String>(0))
1152 .unwrap()
1153 .collect::<Result<Vec<_>, _>>()
1154 .unwrap();
1155 let version: i64 = connection
1156 .query_row("PRAGMA user_version", [], |row| row.get(0))
1157 .unwrap();
1158 assert_eq!(tables, vec!["audio_recordings", "audio_transcript_pieces"]);
1159 assert_eq!(version, LATEST_SCHEMA_VERSION);
1160 }
1161
1162 #[test]
1163 fn version_five_databases_upgrade_without_losing_legacy_queue_data() {
1164 let connection = Connection::open_in_memory().unwrap();
1165 connection.execute_batch(INITIAL_MIGRATION).unwrap();
1166 connection
1167 .execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)
1168 .unwrap();
1169 connection
1170 .execute_batch(TRANSCRIPTION_STATUS_MIGRATION)
1171 .unwrap();
1172 connection
1173 .execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)
1174 .unwrap();
1175 connection
1176 .execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)
1177 .unwrap();
1178 let id = Uuid::new_v4();
1179 insert_recording(&connection, id, "failed");
1180
1181 apply_migrations(&connection).unwrap();
1182
1183 let version: i64 = connection
1184 .query_row("PRAGMA user_version", [], |row| row.get(0))
1185 .unwrap();
1186 let retryable: i64 = connection
1187 .query_row(
1188 "SELECT failure_retryable FROM audio_recordings WHERE id=?1",
1189 [id.to_string()],
1190 |row| row.get(0),
1191 )
1192 .unwrap();
1193 let legacy_queue_exists: i64 = connection
1194 .query_row(
1195 "SELECT EXISTS(
1196 SELECT 1 FROM sqlite_schema
1197 WHERE type='table' AND name='audio_ingress_pieces'
1198 )",
1199 [],
1200 |row| row.get(0),
1201 )
1202 .unwrap();
1203 assert_eq!(version, LATEST_SCHEMA_VERSION);
1204 assert_eq!(retryable, 1);
1205 assert_eq!(legacy_queue_exists, 1);
1206 }
1207
1208 #[test]
1209 fn version_seven_piece_rows_migrate_without_loss() {
1210 let connection = Connection::open_in_memory().unwrap();
1211 connection.execute_batch(INITIAL_MIGRATION).unwrap();
1212 connection
1213 .execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)
1214 .unwrap();
1215 connection
1216 .execute_batch(TRANSCRIPTION_STATUS_MIGRATION)
1217 .unwrap();
1218 connection
1219 .execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)
1220 .unwrap();
1221 connection
1222 .execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)
1223 .unwrap();
1224 connection
1225 .execute_batch(STANDALONE_LIBRARY_MIGRATION)
1226 .unwrap();
1227 connection
1228 .execute_batch(DURABLE_TRANSCRIPT_PIECES_MIGRATION)
1229 .unwrap();
1230 let id = Uuid::new_v4();
1231 insert_recording(&connection, id, "transcribing");
1232 connection
1233 .execute(
1234 "INSERT INTO audio_transcript_pieces(
1235 recording_id,attempt,piece_index,piece_count,audio_start_ms,
1236 audio_end_ms,transcript_json,created_at
1237 ) VALUES(?1,1,0,2,0,120000,?2,?3)",
1238 params![
1239 id.to_string(),
1240 r#"{"utterances":[{"original_text":"hello"}]}"#,
1241 "2026-01-01T00:00:00Z",
1242 ],
1243 )
1244 .unwrap();
1245
1246 apply_migrations(&connection).unwrap();
1247
1248 let migrated: (String, String, String) = connection
1249 .query_row(
1250 "SELECT attempt_id,cache_revision,transcript_json
1251 FROM audio_transcript_pieces
1252 WHERE recording_id=?1 AND piece_index=0",
1253 [id.to_string()],
1254 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
1255 )
1256 .unwrap();
1257 assert_eq!(migrated.0, "version-7-attempt-1");
1258 assert_eq!(migrated.1, PIECE_CACHE_REVISION);
1259 assert!(migrated.2.contains("hello"));
1260 }
1261
1262 #[test]
1263 fn status_exposes_one_complete_transcript() {
1264 let connection = database();
1265 let id = Uuid::new_v4();
1266 insert_recording(&connection, id, "ready_for_ingress");
1267 connection
1268 .execute(
1269 "UPDATE audio_recordings SET final_transcript='hello' WHERE id=?1",
1270 [id.to_string()],
1271 )
1272 .unwrap();
1273 let status = connection
1274 .query_row(
1275 "SELECT id,sha256,original_filename,size_bytes,source_created_at,received_at,
1276 status,gemini_model,reconciliation_model,reconciliation_reasoning,
1277 attempt_count,last_error,failure_retryable,transcription_status_json,
1278 final_transcript
1279 FROM audio_recordings WHERE id=?1",
1280 [id.to_string()],
1281 row_recording_status,
1282 )
1283 .unwrap();
1284 assert!(matches!(
1285 status.state,
1286 RecordingState::Complete { ref transcript } if transcript == "hello"
1287 ));
1288 }
1289
1290 #[test]
1291 fn transcript_pieces_are_durable_and_attempt_scoped() {
1292 let connection = database();
1293 let id = Uuid::new_v4();
1294 insert_recording(&connection, id, "transcribing");
1295 let db = Mutex::new(connection);
1296 let piece = sample_piece();
1297 persist_transcript_piece(&db, id, "attempt-a", &piece).unwrap();
1298 persist_transcript_piece(&db, id, "attempt-b", &piece).unwrap();
1299
1300 let db = db.lock().unwrap();
1301 let rows: i64 = db
1302 .query_row(
1303 "SELECT COUNT(*) FROM audio_transcript_pieces WHERE recording_id=?1",
1304 [id.to_string()],
1305 |row| row.get(0),
1306 )
1307 .unwrap();
1308 let stored: String = db
1309 .query_row(
1310 "SELECT transcript_json FROM audio_transcript_pieces
1311 WHERE recording_id=?1 AND attempt_id='attempt-a' AND piece_index=0",
1312 [id.to_string()],
1313 |row| row.get(0),
1314 )
1315 .unwrap();
1316 assert_eq!(rows, 2);
1317 assert_eq!(stored, piece.transcript);
1318 }
1319
1320 #[test]
1321 fn cache_reuses_only_exact_matching_piece_plans() {
1322 let connection = database();
1323 let id = Uuid::new_v4();
1324 insert_recording(&connection, id, "transcribing");
1325 let db = Mutex::new(connection);
1326 let piece = sample_piece();
1327 persist_transcript_piece(&db, id, "attempt-a", &piece).unwrap();
1328
1329 let exact = load_cached_transcript_piece(&db, id, piece.plan).unwrap();
1330 let changed = load_cached_transcript_piece(
1331 &db,
1332 id,
1333 ChunkPlan {
1334 end_ms: piece.plan.end_ms + 1,
1335 ..piece.plan
1336 },
1337 )
1338 .unwrap();
1339
1340 assert_eq!(exact.as_deref(), Some(piece.transcript.as_str()));
1341 assert_eq!(changed, None);
1342 }
1343
1344 #[test]
1345 fn future_schema_versions_are_rejected() {
1346 let connection = Connection::open_in_memory().unwrap();
1347 connection
1348 .execute_batch("PRAGMA user_version = 9;")
1349 .unwrap();
1350 let error = apply_migrations(&connection).unwrap_err().to_string();
1351 assert!(error.contains("newer than supported"));
1352 }
1353
1354 #[test]
1355 fn interrupted_attempts_consume_the_fixed_budget() {
1356 let connection = database();
1357 let id = Uuid::new_v4();
1358 insert_recording(&connection, id, "transcribing");
1359 connection
1360 .execute(
1361 "UPDATE audio_recordings SET attempt_count=5 WHERE id=?1",
1362 [id.to_string()],
1363 )
1364 .unwrap();
1365 recover_interrupted_attempts(&connection).unwrap();
1366 let state: String = connection
1367 .query_row(
1368 "SELECT status FROM audio_recordings WHERE id=?1",
1369 [id.to_string()],
1370 |row| row.get(0),
1371 )
1372 .unwrap();
1373 assert_eq!(state, "failed");
1374 }
1375
1376 #[test]
1377 fn filenames_cannot_escape_the_persistence_root() {
1378 assert_eq!(safe_filename(Some("../../secret.wav")), "secret.wav");
1379 assert_eq!(safe_filename(Some("meeting note.wav")), "meeting_note.wav");
1380 assert_eq!(safe_filename(None), "audio.wav");
1381 }
1382}