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