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 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#[derive(Clone, Debug)]
80pub struct AudioInput {
81 pub bytes: Vec<u8>,
83 pub recorded_at: DateTime<Utc>,
85 pub original_filename: Option<String>,
87}
88
89#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
91pub struct Submission {
92 pub recording_id: Uuid,
94 pub deduplicated: bool,
96}
97
98#[derive(Clone, Debug, Serialize)]
100pub struct Status {
101 pub recordings: Vec<RecordingStatus>,
103}
104
105#[derive(Clone, Debug, Serialize)]
107pub struct RecordingStatus {
108 pub id: Uuid,
110 pub sha256: String,
112 pub original_filename: String,
114 pub size_bytes: u64,
116 pub recorded_at: DateTime<Utc>,
118 pub received_at: DateTime<Utc>,
120 pub transcription_model: String,
122 pub reconciliation_model: String,
124 pub reconciliation_reasoning: String,
126 pub state: RecordingState,
128}
129
130#[derive(Clone, Debug, Serialize)]
132#[serde(tag = "kind", rename_all = "snake_case")]
133pub enum RecordingState {
134 Queued,
136 Processing {
138 attempt: u8,
140 progress: TranscriptionStatus,
142 },
143 Complete {
145 transcript: String,
147 },
148 Failed {
150 attempts: u8,
152 error: String,
154 retryable: bool,
156 },
157}
158
159#[derive(Clone, Copy, Debug, Eq, PartialEq)]
161pub enum ErrorKind {
162 InvalidInput,
164 NotFound,
166 Conflict,
168 Internal,
170}
171
172#[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 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#[derive(Clone)]
240pub struct AudioIngress {
241 inner: Arc<Inner>,
242}
243
244impl AudioIngress {
245 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 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 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 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}