1#![deny(missing_docs)]
8#![forbid(unsafe_code)]
9
10use std::{
11 collections::{HashMap, HashSet},
12 fs,
13 path::{Path, PathBuf},
14 sync::{Arc, Mutex, Weak},
15 time::Duration,
16};
17
18use anyhow::{Context, ensure};
19use chrono::{DateTime, Duration as ChronoDuration, Utc};
20use kcode_speaker_system::SpeechClassifier;
21use rusqlite::{Connection, OptionalExtension, params};
22use serde::Serialize;
23use serde_json::Value;
24use sha2::{Digest, Sha256};
25use tokio::io::AsyncWriteExt;
26use uuid::Uuid;
27
28pub use identity::{
29 CLASSIFIER_MODEL, CLASSIFIER_PROMPT_VERSION, CLASSIFIER_PROVIDER, CLASSIFIER_SCHEMA_VERSION,
30 CandidateMapping, ConfirmationState, CorrectionChunk, CorrectionObservation, CorrectionPacket,
31 FeatureRow, ObservationConfirmation, ObservationKey, ParsedChunk, ParsedSpeaker,
32 ParsedUtterance, RecordingConfirmation,
33};
34use identity::{
35 ClassificationContext, apply_confirmations, classify_speakers, restore_packet_training,
36 validate_parsed_chunk,
37};
38pub use transcribe::{
39 AudioChunkCall, AudioChunkRequest, AudioTranscriber, IntelligenceError, IntelligenceFuture,
40 JobState, RECONCILIATION_MODEL, RECONCILIATION_REASONING, Step, StepError, StepState,
41 StepStatus, TRANSCRIPTION_MODEL, TextGenerationCall, TextGenerationRequest, TranscriptionJob,
42 TranscriptionStatus,
43};
44use transcribe::{ChunkPlan, ChunkTranscript, PIECE_CACHE_REVISION, PieceCache, PieceSink};
45
46mod identity;
47mod transcribe;
48
49const INITIAL_MIGRATION: &str = include_str!("../migrations/001_initial.sql");
50const RELEASE_DEFERRED_INGRESS_MIGRATION: &str =
51 include_str!("../migrations/002_release_deferred_ingress.sql");
52const TRANSCRIPTION_STATUS_MIGRATION: &str =
53 include_str!("../migrations/003_transcription_status.sql");
54const RETRY_ROUNDED_WAV_INTERVALS_MIGRATION: &str =
55 include_str!("../migrations/004_retry_rounded_wav_intervals.sql");
56const UNIFIED_INGRESS_QUEUE_MIGRATION: &str =
57 include_str!("../migrations/005_unified_ingress_queue.sql");
58const STANDALONE_LIBRARY_MIGRATION: &str = include_str!("../migrations/006_standalone_library.sql");
59const DURABLE_TRANSCRIPT_PIECES_MIGRATION: &str =
60 include_str!("../migrations/007_durable_transcript_pieces.sql");
61const UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION: &str =
62 include_str!("../migrations/008_unique_transcription_attempts.sql");
63const USAGE_USER_MIGRATION: &str = include_str!("../migrations/009_usage_user.sql");
64const SPEAKER_CORRECTION_PACKETS_MIGRATION: &str =
65 include_str!("../migrations/010_speaker_correction_packets.sql");
66
67const DATABASE_FILENAME: &str = "state.sqlite3";
68const ORIGINALS_DIRECTORY: &str = "originals";
69const FAILURE_LIMIT: i64 = 5;
70const RETRY_DELAY_SECONDS: i64 = 15;
71const LATEST_SCHEMA_VERSION: i64 = 10;
72
73const FRESH_SCHEMA: &str = r#"
74CREATE TABLE audio_recordings (
75 id TEXT PRIMARY KEY NOT NULL,
76 user_id TEXT NOT NULL CHECK(length(user_id) > 0),
77 sha256 TEXT NOT NULL UNIQUE CHECK(length(sha256) = 64),
78 original_filename TEXT NOT NULL,
79 content_type TEXT NOT NULL,
80 size_bytes INTEGER NOT NULL CHECK(size_bytes >= 0),
81 source_created_at TEXT NOT NULL,
82 received_at TEXT NOT NULL,
83 updated_at TEXT NOT NULL,
84 original_relative_path TEXT NOT NULL,
85 status TEXT NOT NULL CHECK(status IN (
86 'uploaded', 'chunking', 'transcribing', 'reconciling',
87 'ready_for_ingress', 'ingressing', 'ingress_failed', 'complete', 'failed'
88 )),
89 gemini_model TEXT NOT NULL,
90 reconciliation_model TEXT NOT NULL,
91 reconciliation_reasoning TEXT NOT NULL,
92 final_transcript TEXT,
93 attempt_count INTEGER NOT NULL DEFAULT 0 CHECK(attempt_count >= 0),
94 next_attempt_at TEXT,
95 last_error TEXT,
96 transcription_status_json TEXT,
97 failure_retryable INTEGER NOT NULL DEFAULT 1
98 CHECK(failure_retryable IN (0, 1)),
99 correction_packet_json TEXT
100);
101
102CREATE INDEX audio_recordings_work_queue
103ON audio_recordings(status, next_attempt_at, received_at);
104
105CREATE TABLE audio_transcript_pieces (
106 recording_id TEXT NOT NULL REFERENCES audio_recordings(id) ON DELETE CASCADE,
107 attempt_id TEXT NOT NULL CHECK(length(attempt_id) > 0),
108 cache_revision TEXT NOT NULL CHECK(length(cache_revision) > 0),
109 piece_index INTEGER NOT NULL CHECK(piece_index >= 0),
110 piece_count INTEGER NOT NULL CHECK(piece_count > 0 AND piece_index < piece_count),
111 audio_start_ms INTEGER NOT NULL CHECK(audio_start_ms >= 0),
112 audio_end_ms INTEGER NOT NULL CHECK(audio_end_ms > audio_start_ms),
113 transcript_json TEXT NOT NULL,
114 raw_gemini_response TEXT NOT NULL,
115 parsed_json TEXT NOT NULL,
116 created_at TEXT NOT NULL,
117 PRIMARY KEY(recording_id, attempt_id, piece_index)
118);
119
120CREATE INDEX audio_transcript_pieces_cache_lookup
121ON audio_transcript_pieces(
122 recording_id,
123 cache_revision,
124 piece_index,
125 piece_count,
126 audio_start_ms,
127 audio_end_ms,
128 created_at
129);
130
131PRAGMA user_version = 10;
132"#;
133
134#[derive(Clone, Debug)]
136pub struct AudioInput {
137 pub user_id: String,
139 pub bytes: Vec<u8>,
141 pub recorded_at: DateTime<Utc>,
143 pub original_filename: Option<String>,
145}
146
147#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
149pub struct Submission {
150 pub recording_id: Uuid,
152 pub deduplicated: bool,
154}
155
156#[derive(Clone, Debug, Serialize)]
158pub struct Status {
159 pub recordings: Vec<RecordingStatus>,
161}
162
163#[derive(Clone, Debug, Serialize)]
165pub struct RecordingStatus {
166 pub id: Uuid,
168 pub user_id: String,
170 pub sha256: String,
172 pub original_filename: String,
174 pub size_bytes: u64,
176 pub recorded_at: DateTime<Utc>,
178 pub received_at: DateTime<Utc>,
180 pub transcription_model: String,
182 pub reconciliation_model: String,
184 pub reconciliation_reasoning: String,
186 pub state: RecordingState,
188 pub correction_packet: Option<CorrectionPacket>,
190}
191
192#[derive(Clone, Debug, Serialize)]
194#[serde(tag = "kind", rename_all = "snake_case")]
195pub enum RecordingState {
196 Queued,
198 Processing {
200 attempt: u8,
202 progress: TranscriptionStatus,
204 },
205 Complete {
207 transcript: String,
209 },
210 Failed {
212 attempts: u8,
214 error: String,
216 retryable: bool,
218 },
219}
220
221#[derive(Clone, Copy, Debug, Eq, PartialEq)]
223pub enum ErrorKind {
224 InvalidInput,
226 NotFound,
228 Conflict,
230 Internal,
232}
233
234#[derive(Debug)]
236pub struct Error {
237 kind: ErrorKind,
238 message: String,
239}
240
241impl Error {
242 fn invalid(message: impl Into<String>) -> Self {
243 Self {
244 kind: ErrorKind::InvalidInput,
245 message: message.into(),
246 }
247 }
248
249 fn not_found() -> Self {
250 Self {
251 kind: ErrorKind::NotFound,
252 message: "Audio recording not found.".into(),
253 }
254 }
255
256 fn conflict(message: impl Into<String>) -> Self {
257 Self {
258 kind: ErrorKind::Conflict,
259 message: message.into(),
260 }
261 }
262
263 fn internal(error: impl std::fmt::Display) -> Self {
264 tracing::error!(%error, "AudioIngress operation failed");
265 Self {
266 kind: ErrorKind::Internal,
267 message: "An unexpected AudioIngress error occurred.".into(),
268 }
269 }
270
271 pub fn kind(&self) -> ErrorKind {
273 self.kind
274 }
275}
276
277impl std::fmt::Display for Error {
278 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 formatter.write_str(&self.message)
280 }
281}
282
283impl std::error::Error for Error {}
284
285struct TemporaryUpload(PathBuf);
286
287impl Drop for TemporaryUpload {
288 fn drop(&mut self) {
289 let _ = fs::remove_file(&self.0);
290 }
291}
292
293struct Inner {
294 root: PathBuf,
295 db: Arc<Mutex<Connection>>,
296 classifier: Arc<SpeechClassifier>,
297 transcriber: AudioTranscriber,
298 jobs: Mutex<HashMap<Uuid, TranscriptionJob>>,
299}
300
301#[derive(Clone)]
303pub struct AudioIngress {
304 inner: Arc<Inner>,
305}
306
307impl AudioIngress {
308 pub async fn open(
314 persistence_root: impl AsRef<Path>,
315 transcriber: AudioTranscriber,
316 classifier: Arc<SpeechClassifier>,
317 ) -> Result<Self, Error> {
318 let root = persistence_root.as_ref().to_path_buf();
319 ensure_private_directory(&root).map_err(Error::internal)?;
320 ensure_private_directory(&root.join(ORIGINALS_DIRECTORY)).map_err(Error::internal)?;
321
322 let connection = Connection::open(root.join(DATABASE_FILENAME)).map_err(Error::internal)?;
323 connection
324 .execute_batch(
325 "PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA busy_timeout=15000;",
326 )
327 .map_err(Error::internal)?;
328 apply_migrations(&connection).map_err(Error::internal)?;
329 recover_interrupted_attempts(&connection).map_err(Error::internal)?;
330
331 let inner = Arc::new(Inner {
332 root,
333 db: Arc::new(Mutex::new(connection)),
334 classifier,
335 transcriber,
336 jobs: Mutex::new(HashMap::new()),
337 });
338 tokio::spawn(worker_loop(Arc::downgrade(&inner)));
339 Ok(Self { inner })
340 }
341
342 pub async fn submit(&self, input: AudioInput) -> Result<Submission, Error> {
344 if input.user_id.trim().is_empty() || input.user_id.chars().count() > 256 {
345 return Err(Error::invalid(
346 "User ID must contain between 1 and 256 characters.",
347 ));
348 }
349 if input.bytes.is_empty() {
350 return Err(Error::invalid("Audio bytes must not be empty."));
351 }
352 let size_bytes = i64::try_from(input.bytes.len())
353 .map_err(|_| Error::invalid("Audio is too large for this platform."))?;
354 let sha256 = format!("{:x}", Sha256::digest(&input.bytes));
355 if let Some(id) = self.recording_id_by_sha(&sha256)? {
356 return Ok(Submission {
357 recording_id: id,
358 deduplicated: true,
359 });
360 }
361
362 let upload_id = Uuid::new_v4();
363 let temporary = TemporaryUpload(self.inner.root.join(format!(".upload-{upload_id}.tmp")));
364 let mut file = tokio::fs::OpenOptions::new()
365 .write(true)
366 .create_new(true)
367 .open(&temporary.0)
368 .await
369 .map_err(Error::internal)?;
370 set_private_file(&temporary.0).map_err(Error::internal)?;
371 file.write_all(&input.bytes)
372 .await
373 .map_err(Error::internal)?;
374 file.sync_all().await.map_err(Error::internal)?;
375 drop(file);
376
377 let relative_path = format!("{ORIGINALS_DIRECTORY}/{sha256}.wav");
378 let final_path = self.inner.root.join(&relative_path);
379 if final_path.exists() {
380 tokio::fs::remove_file(&temporary.0)
381 .await
382 .map_err(Error::internal)?;
383 } else {
384 tokio::fs::rename(&temporary.0, &final_path)
385 .await
386 .map_err(Error::internal)?;
387 }
388 set_private_file(&final_path).map_err(Error::internal)?;
389 sync_file(&final_path).map_err(Error::internal)?;
390 sync_directory(final_path.parent().unwrap_or(&self.inner.root)).map_err(Error::internal)?;
391 sync_directory(&self.inner.root).map_err(Error::internal)?;
392
393 let id = Uuid::new_v4();
394 let now = Utc::now().to_rfc3339();
395 let filename = safe_filename(input.original_filename.as_deref());
396 let insert = {
397 let db = self.inner.db.lock().map_err(Error::internal)?;
398 db.execute(
399 "INSERT INTO audio_recordings(
400 id,user_id,sha256,original_filename,content_type,size_bytes,
401 source_created_at,received_at,updated_at,original_relative_path,
402 status,gemini_model,reconciliation_model,reconciliation_reasoning
403 ) VALUES(?1,?2,?3,?4,'audio/wav',?5,?6,?7,?7,?8,'uploaded',?9,?10,?11)",
404 params![
405 id.to_string(),
406 input.user_id,
407 sha256,
408 filename,
409 size_bytes,
410 input.recorded_at.to_rfc3339(),
411 now,
412 relative_path,
413 TRANSCRIPTION_MODEL,
414 RECONCILIATION_MODEL,
415 RECONCILIATION_REASONING,
416 ],
417 )
418 };
419 if let Err(error) = insert {
420 if let Some(existing) = self.recording_id_by_sha(&sha256)? {
421 return Ok(Submission {
422 recording_id: existing,
423 deduplicated: true,
424 });
425 }
426 return Err(Error::internal(error));
427 }
428 tracing::info!(recording_id=%id, %sha256, bytes=size_bytes, "Durably accepted audio");
429 Ok(Submission {
430 recording_id: id,
431 deduplicated: false,
432 })
433 }
434
435 pub fn status(&self) -> Result<Status, Error> {
437 let db = self.inner.db.lock().map_err(Error::internal)?;
438 let mut statement = db
439 .prepare(
440 "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,received_at,
441 status,gemini_model,reconciliation_model,reconciliation_reasoning,
442 attempt_count,last_error,failure_retryable,transcription_status_json,
443 final_transcript,correction_packet_json
444 FROM audio_recordings
445 ORDER BY datetime(source_created_at) DESC,datetime(received_at) DESC,id DESC",
446 )
447 .map_err(Error::internal)?;
448 let recordings = statement
449 .query_map([], row_recording_status)
450 .map_err(Error::internal)?
451 .collect::<Result<Vec<_>, _>>()
452 .map_err(Error::internal)?;
453 Ok(Status { recordings })
454 }
455
456 pub fn retry(&self, recording_id: Uuid) -> Result<(), Error> {
458 let db = self.inner.db.lock().map_err(Error::internal)?;
459 let changed = db
460 .execute(
461 "UPDATE audio_recordings
462 SET status='uploaded',attempt_count=0,next_attempt_at=NULL,last_error=NULL,
463 transcription_status_json=NULL,failure_retryable=1,updated_at=?1
464 WHERE id=?2 AND status='failed'",
465 params![Utc::now().to_rfc3339(), recording_id.to_string()],
466 )
467 .map_err(Error::internal)?;
468 if changed == 1 {
469 return Ok(());
470 }
471 let exists = db
472 .query_row(
473 "SELECT 1 FROM audio_recordings WHERE id=?1",
474 [recording_id.to_string()],
475 |row| row.get::<_, i64>(0),
476 )
477 .optional()
478 .map_err(Error::internal)?
479 .is_some();
480 if exists {
481 Err(Error::conflict("Only a failed recording can be retried."))
482 } else {
483 Err(Error::not_found())
484 }
485 }
486
487 pub fn confirm_speakers(
494 &self,
495 confirmation: RecordingConfirmation,
496 ) -> Result<CorrectionPacket, Error> {
497 let recording_id = confirmation.recording_id;
498 let db = self.inner.db.lock().map_err(Error::internal)?;
499 let stored = db
500 .query_row(
501 "SELECT status,correction_packet_json
502 FROM audio_recordings WHERE id=?1",
503 [recording_id.to_string()],
504 |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?)),
505 )
506 .optional()
507 .map_err(Error::internal)?;
508 let Some((status, packet_json)) = stored else {
509 return Err(Error::not_found());
510 };
511 if !matches!(
512 status.as_str(),
513 "ready_for_ingress" | "ingressing" | "ingress_failed" | "complete"
514 ) {
515 return Err(Error::conflict(
516 "Speaker confirmation requires a completed recording.",
517 ));
518 }
519 let packet_json = packet_json
520 .ok_or_else(|| Error::conflict("The completed recording has no correction packet."))?;
521 let mut stored = StoredCorrectionPacket::decode(&packet_json).map_err(Error::internal)?;
522 identity::validate_confirmation_coverage(&stored.packet, &confirmation)
523 .map_err(Error::invalid)?;
524 let legacy_observation_keys = stored.legacy_observation_keys().map_err(Error::internal)?;
525
526 let previous = stored.packet.clone();
527 apply_confirmations(
528 &self.inner.classifier,
529 &mut stored.packet,
530 &confirmation,
531 &legacy_observation_keys,
532 )
533 .map_err(Error::internal)?;
534 let updated_json = stored.encode().map_err(Error::internal)?;
535 if let Err(error) = db.execute(
536 "UPDATE audio_recordings
537 SET correction_packet_json=?1,updated_at=?2
538 WHERE id=?3",
539 params![
540 updated_json,
541 Utc::now().to_rfc3339(),
542 recording_id.to_string()
543 ],
544 ) {
545 let rollback_errors = restore_packet_training(
546 &self.inner.classifier,
547 &previous,
548 &legacy_observation_keys,
549 );
550 if !rollback_errors.is_empty() {
551 tracing::error!(
552 recording_id=%recording_id,
553 errors=?rollback_errors,
554 "Could not fully restore classifier state after packet persistence failed"
555 );
556 }
557 return Err(Error::internal(error));
558 }
559 Ok(stored.packet)
560 }
561
562 fn recording_id_by_sha(&self, sha256: &str) -> Result<Option<Uuid>, Error> {
563 let db = self.inner.db.lock().map_err(Error::internal)?;
564 db.query_row(
565 "SELECT id FROM audio_recordings WHERE sha256=?1",
566 [sha256],
567 |row| row.get::<_, String>(0),
568 )
569 .optional()
570 .map_err(Error::internal)?
571 .map(|id| Uuid::parse_str(&id).map_err(Error::internal))
572 .transpose()
573 }
574}
575
576fn row_recording_status(row: &rusqlite::Row<'_>) -> rusqlite::Result<RecordingStatus> {
577 let id: String = row.get(0)?;
578 let recorded_at: String = row.get(5)?;
579 let received_at: String = row.get(6)?;
580 let durable_status: String = row.get(7)?;
581 let attempts: i64 = row.get(11)?;
582 let last_error: Option<String> = row.get(12)?;
583 let retryable: i64 = row.get(13)?;
584 let progress_json: Option<String> = row.get(14)?;
585 let transcript: Option<String> = row.get(15)?;
586 let correction_packet_json: Option<String> = row.get(16)?;
587 let parse_time = |index, value: &str| {
588 DateTime::parse_from_rfc3339(value)
589 .map(|value| value.with_timezone(&Utc))
590 .map_err(|error| {
591 rusqlite::Error::FromSqlConversionFailure(
592 index,
593 rusqlite::types::Type::Text,
594 Box::new(error),
595 )
596 })
597 };
598 let state = match durable_status.as_str() {
599 "uploaded" => RecordingState::Queued,
600 "chunking" | "transcribing" | "reconciling" => {
601 let progress = progress_json
602 .as_deref()
603 .map(serde_json::from_str)
604 .transpose()
605 .map_err(|error| {
606 rusqlite::Error::FromSqlConversionFailure(
607 14,
608 rusqlite::types::Type::Text,
609 Box::new(error),
610 )
611 })?
612 .unwrap_or_else(initial_progress);
613 RecordingState::Processing {
614 attempt: attempts.clamp(0, i64::from(u8::MAX)) as u8,
615 progress: without_results(progress),
616 }
617 }
618 "ready_for_ingress" | "ingressing" | "ingress_failed" | "complete" => {
619 RecordingState::Complete {
620 transcript: transcript.unwrap_or_default(),
621 }
622 }
623 "failed" => RecordingState::Failed {
624 attempts: attempts.clamp(0, i64::from(u8::MAX)) as u8,
625 error: last_error.unwrap_or_else(|| "Audio processing failed.".into()),
626 retryable: retryable != 0,
627 },
628 other => {
629 return Err(rusqlite::Error::FromSqlConversionFailure(
630 7,
631 rusqlite::types::Type::Text,
632 format!("unknown audio status {other:?}").into(),
633 ));
634 }
635 };
636 let correction_packet = correction_packet_json
637 .as_deref()
638 .map(StoredCorrectionPacket::decode)
639 .transpose()
640 .map_err(|error| {
641 rusqlite::Error::FromSqlConversionFailure(
642 16,
643 rusqlite::types::Type::Text,
644 Box::new(error),
645 )
646 })?
647 .map(|stored| stored.packet);
648 Ok(RecordingStatus {
649 id: Uuid::parse_str(&id).map_err(|error| {
650 rusqlite::Error::FromSqlConversionFailure(
651 0,
652 rusqlite::types::Type::Text,
653 Box::new(error),
654 )
655 })?,
656 user_id: row.get(1)?,
657 sha256: row.get(2)?,
658 original_filename: row.get(3)?,
659 size_bytes: u64::try_from(row.get::<_, i64>(4)?).map_err(|error| {
660 rusqlite::Error::FromSqlConversionFailure(
661 4,
662 rusqlite::types::Type::Integer,
663 Box::new(error),
664 )
665 })?,
666 recorded_at: parse_time(5, &recorded_at)?,
667 received_at: parse_time(6, &received_at)?,
668 transcription_model: row.get(8)?,
669 reconciliation_model: row.get(9)?,
670 reconciliation_reasoning: row.get(10)?,
671 state,
672 correction_packet,
673 })
674}
675
676#[derive(Clone, Debug)]
677struct LegacyFeatureRow {
678 chunk_position: usize,
679 speaker_position: usize,
680 value: Value,
681}
682
683#[derive(Clone, Debug)]
684struct StoredCorrectionPacket {
685 packet: CorrectionPacket,
686 legacy_feature_rows: Vec<LegacyFeatureRow>,
687}
688
689impl StoredCorrectionPacket {
690 fn decode(serialized: &str) -> serde_json::Result<Self> {
691 match serde_json::from_str(serialized) {
692 Ok(packet) => Ok(Self {
693 packet,
694 legacy_feature_rows: Vec::new(),
695 }),
696 Err(current_error) => {
697 let mut value: Value = serde_json::from_str(serialized)?;
698 let Some(chunks) = value.get_mut("chunks").and_then(Value::as_array_mut) else {
699 return Err(current_error);
700 };
701 let mut legacy_feature_rows = Vec::new();
702 for (chunk_position, chunk) in chunks.iter_mut().enumerate() {
703 let Some(speakers) = chunk
704 .pointer_mut("/parsed/speakers")
705 .and_then(Value::as_array_mut)
706 else {
707 continue;
708 };
709 for (speaker_position, speaker) in speakers.iter_mut().enumerate() {
710 let Some(feature_row) = speaker.get_mut("feature_row") else {
711 continue;
712 };
713 if is_legacy_feature_row(feature_row) {
714 legacy_feature_rows.push(LegacyFeatureRow {
715 chunk_position,
716 speaker_position,
717 value: feature_row.take(),
718 });
719 }
720 }
721 }
722 if legacy_feature_rows.is_empty() {
723 return Err(current_error);
724 }
725 let packet = serde_json::from_value(value)?;
726 Ok(Self {
727 packet,
728 legacy_feature_rows,
729 })
730 }
731 }
732 }
733
734 fn encode(&self) -> serde_json::Result<String> {
735 let mut value = serde_json::to_value(&self.packet)?;
736 for legacy in &self.legacy_feature_rows {
737 let feature_row = value
738 .get_mut("chunks")
739 .and_then(Value::as_array_mut)
740 .and_then(|chunks| chunks.get_mut(legacy.chunk_position))
741 .and_then(|chunk| chunk.pointer_mut("/parsed/speakers"))
742 .and_then(Value::as_array_mut)
743 .and_then(|speakers| speakers.get_mut(legacy.speaker_position))
744 .and_then(|speaker| speaker.get_mut("feature_row"))
745 .expect("decoded correction packet retains its speaker positions");
746 *feature_row = legacy.value.clone();
747 }
748 serde_json::to_string(&value)
749 }
750
751 fn legacy_observation_keys(&self) -> anyhow::Result<HashSet<(String, u32)>> {
752 let mut keys = HashSet::new();
753 for legacy in &self.legacy_feature_rows {
754 let chunk = self
755 .packet
756 .chunks
757 .get(legacy.chunk_position)
758 .context("legacy feature row is outside its correction packet")?;
759 let speaker = chunk
760 .parsed
761 .speakers
762 .get(legacy.speaker_position)
763 .context("legacy feature row is outside its parsed speakers")?;
764 let observation = chunk
765 .observations
766 .iter()
767 .find(|observation| {
768 observation.speaker_ordinal as usize == legacy.speaker_position
769 && observation.local_label == speaker.local_label
770 })
771 .context("legacy feature row has no matching correction observation")?;
772 ensure!(
773 keys.insert((
774 observation.observation_key.object_id.clone(),
775 observation.observation_key.piece_index,
776 )),
777 "legacy correction packet repeats an observation key"
778 );
779 }
780 Ok(keys)
781 }
782}
783
784fn is_legacy_feature_row(value: &Value) -> bool {
785 const FIELDS: [&str; 24] = [
786 "accent_variety",
787 "articulation_rate_syllables_per_second",
788 "breathiness",
789 "cefr",
790 "consonant_cluster_reduction_percent",
791 "creaky_phonation_percent",
792 "f0_pitch_span_semitones",
793 "filled_pauses_per_100_words",
794 "foreign_accentedness",
795 "formant_dispersion_hz",
796 "hypernasality",
797 "lateral_realization",
798 "lexical_stress_accuracy_percent",
799 "median_f0_hz",
800 "monophthongization_percent",
801 "npvi_v",
802 "perceived_age",
803 "rhotic_realization",
804 "roughness",
805 "s_realization",
806 "unstressed_vowel_reduction_percent",
807 "vai",
808 "vocal_gender_presentation",
809 "word_initial_stressed_prevocalic_t_vot_ms",
810 ];
811 value.as_object().is_some_and(|object| {
812 object.len() == FIELDS.len() && FIELDS.iter().all(|field| object.contains_key(*field))
813 })
814}
815
816async fn worker_loop(inner: Weak<Inner>) {
817 loop {
818 let Some(inner) = inner.upgrade() else {
819 return;
820 };
821 let worked = match process_next_recording(&inner).await {
822 Ok(worked) => worked,
823 Err(error) => {
824 tracing::error!(error=%error, "AudioIngress worker iteration failed");
825 false
826 }
827 };
828 drop(inner);
829 tokio::time::sleep(if worked {
830 Duration::from_millis(100)
831 } else {
832 Duration::from_secs(5)
833 })
834 .await;
835 }
836}
837
838#[derive(Debug)]
839struct WorkRecording {
840 id: Uuid,
841 user_id: String,
842 sha256: String,
843 original_filename: String,
844 size_bytes: u64,
845 recorded_at: DateTime<Utc>,
846 original_relative_path: String,
847 attempt_count: i64,
848}
849
850async fn process_next_recording(inner: &Inner) -> anyhow::Result<bool> {
851 let recording = {
852 let db = inner
853 .db
854 .lock()
855 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
856 fetch_work_recording(&db)?
857 };
858 let Some(recording) = recording else {
859 return Ok(false);
860 };
861 poll_transcription(inner, recording).await?;
862 Ok(true)
863}
864
865fn fetch_work_recording(db: &Connection) -> anyhow::Result<Option<WorkRecording>> {
866 db.query_row(
867 "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,
868 original_relative_path,attempt_count
869 FROM audio_recordings
870 WHERE status IN ('uploaded','chunking','transcribing','reconciling')
871 AND (next_attempt_at IS NULL OR datetime(next_attempt_at)<=datetime('now'))
872 ORDER BY datetime(received_at),id
873 LIMIT 1",
874 [],
875 |row| {
876 Ok((
877 row.get::<_, String>(0)?,
878 row.get::<_, String>(1)?,
879 row.get::<_, String>(2)?,
880 row.get::<_, String>(3)?,
881 row.get::<_, i64>(4)?,
882 row.get::<_, String>(5)?,
883 row.get::<_, String>(6)?,
884 row.get::<_, i64>(7)?,
885 ))
886 },
887 )
888 .optional()?
889 .map(
890 |(
891 id,
892 user_id,
893 sha256,
894 original_filename,
895 size_bytes,
896 recorded_at,
897 original_relative_path,
898 attempt_count,
899 )| {
900 Ok(WorkRecording {
901 id: Uuid::parse_str(&id)?,
902 user_id,
903 sha256,
904 original_filename,
905 size_bytes: u64::try_from(size_bytes).context("stored audio size is negative")?,
906 recorded_at: DateTime::parse_from_rfc3339(&recorded_at)
907 .context("stored recording time is invalid")?
908 .with_timezone(&Utc),
909 original_relative_path,
910 attempt_count,
911 })
912 },
913 )
914 .transpose()
915}
916
917async fn poll_transcription(inner: &Inner, mut recording: WorkRecording) -> anyhow::Result<()> {
918 let existing = inner
919 .jobs
920 .lock()
921 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
922 .get(&recording.id)
923 .cloned();
924 let job = if let Some(job) = existing {
925 job
926 } else {
927 if recording.attempt_count >= FAILURE_LIMIT {
928 mark_failed(
929 inner,
930 recording.id,
931 recording.attempt_count,
932 true,
933 "Audio transcription exhausted its five automatic attempts.",
934 None,
935 )?;
936 return Ok(());
937 }
938 let source = inner.root.join(&recording.original_relative_path);
939 let audio = tokio::fs::read(&source)
940 .await
941 .with_context(|| format!("reading retained audio {}", source.display()))?;
942 recording.attempt_count += 1;
943 {
944 let db = inner
945 .db
946 .lock()
947 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
948 db.execute(
949 "UPDATE audio_recordings
950 SET status='chunking',attempt_count=?1,next_attempt_at=NULL,last_error=NULL,
951 failure_retryable=1,updated_at=?2
952 WHERE id=?3",
953 params![
954 recording.attempt_count,
955 Utc::now().to_rfc3339(),
956 recording.id.to_string()
957 ],
958 )?;
959 }
960
961 let classification = ClassificationContext {
962 recording_id: recording.id,
963 user_id: recording.user_id.clone(),
964 sha256: recording.sha256.clone(),
965 original_filename: recording.original_filename.clone(),
966 size_bytes: recording.size_bytes,
967 recorded_at: recording.recorded_at,
968 classifier: inner.classifier.clone(),
969 };
970
971 let cache_db = inner.db.clone();
972 let cache_recording_id = recording.id;
973 let cache_classification = classification.clone();
974 let piece_cache: PieceCache = Arc::new(move |plan| {
975 load_cached_transcript_piece(&cache_db, cache_recording_id, plan, &cache_classification)
976 });
977
978 let sink_db = inner.db.clone();
979 let sink_recording_id = recording.id;
980 let attempt_id = Uuid::new_v4().to_string();
981 let piece_sink: PieceSink = Arc::new(move |piece| {
982 persist_transcript_piece(&sink_db, sink_recording_id, &attempt_id, piece)
983 });
984
985 let job = inner.transcriber.transcribe_durably(
986 recording.user_id.clone(),
987 audio,
988 piece_cache,
989 piece_sink,
990 classification,
991 );
992 inner
993 .jobs
994 .lock()
995 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
996 .insert(recording.id, job.clone());
997 job
998 };
999
1000 let snapshot = job.status();
1001 persist_progress(inner, recording.id, &snapshot)?;
1002 match snapshot.state {
1003 JobState::Queued | JobState::Running => Ok(()),
1004 JobState::Completed => {
1005 let transcript = snapshot
1006 .transcript
1007 .clone()
1008 .context("completed transcription omitted its transcript")?;
1009 ensure!(
1010 !transcript.trim().is_empty(),
1011 "completed transcription is empty"
1012 );
1013 let packet = snapshot
1014 .correction_packet
1015 .clone()
1016 .context("completed durable transcription omitted its correction packet")?;
1017 ensure!(
1018 packet.recording_id == recording.id,
1019 "completed correction packet belongs to another recording"
1020 );
1021 let packet_json = serde_json::to_string(&packet)?;
1022 let progress = serde_json::to_string(&without_results(snapshot))?;
1023 {
1024 let db = inner
1025 .db
1026 .lock()
1027 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1028 db.execute(
1029 "UPDATE audio_recordings
1030 SET status='ready_for_ingress',final_transcript=?1,
1031 correction_packet_json=?2,transcription_status_json=?3,
1032 next_attempt_at=NULL,last_error=NULL,failure_retryable=1,updated_at=?4
1033 WHERE id=?5",
1034 params![
1035 transcript.trim(),
1036 packet_json,
1037 progress,
1038 Utc::now().to_rfc3339(),
1039 recording.id.to_string()
1040 ],
1041 )?;
1042 }
1043 remove_job(inner, recording.id)?;
1044 tracing::info!(
1045 recording_id=%recording.id,
1046 clean=packet.clean,
1047 "Audio transcript and correction packet completed"
1048 );
1049 Ok(())
1050 }
1051 JobState::Failed => {
1052 let error = snapshot
1053 .steps
1054 .iter()
1055 .find(|step| step.state == StepState::Failed)
1056 .and_then(|step| step.error.as_ref());
1057 let message = error
1058 .map(|error| error.message.clone())
1059 .unwrap_or_else(|| "Audio transcription failed without detail.".into());
1060 let retryable = error.is_none_or(|error| error.retryable);
1061 record_attempt_failure(
1062 inner,
1063 recording.id,
1064 recording.attempt_count,
1065 retryable,
1066 &message,
1067 Some(snapshot),
1068 )?;
1069 remove_job(inner, recording.id)
1070 }
1071 }
1072}
1073
1074fn record_attempt_failure(
1075 inner: &Inner,
1076 id: Uuid,
1077 attempts: i64,
1078 retryable: bool,
1079 message: &str,
1080 progress: Option<TranscriptionStatus>,
1081) -> anyhow::Result<()> {
1082 if !retryable || attempts >= FAILURE_LIMIT {
1083 return mark_failed(inner, id, attempts, retryable, message, progress);
1084 }
1085 let db = inner
1086 .db
1087 .lock()
1088 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1089 db.execute(
1090 "UPDATE audio_recordings
1091 SET status='uploaded',next_attempt_at=?1,last_error=?2,failure_retryable=1,
1092 transcription_status_json=?3,updated_at=?4
1093 WHERE id=?5",
1094 params![
1095 (Utc::now() + ChronoDuration::seconds(RETRY_DELAY_SECONDS)).to_rfc3339(),
1096 concise(message, 2_000),
1097 progress
1098 .map(without_results)
1099 .map(|progress| serde_json::to_string(&progress))
1100 .transpose()?,
1101 Utc::now().to_rfc3339(),
1102 id.to_string()
1103 ],
1104 )?;
1105 tracing::warn!(recording_id=%id, attempt=attempts, "Audio transcription will retry");
1106 Ok(())
1107}
1108
1109fn mark_failed(
1110 inner: &Inner,
1111 id: Uuid,
1112 attempts: i64,
1113 retryable: bool,
1114 message: &str,
1115 progress: Option<TranscriptionStatus>,
1116) -> anyhow::Result<()> {
1117 let db = inner
1118 .db
1119 .lock()
1120 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1121 db.execute(
1122 "UPDATE audio_recordings
1123 SET status='failed',attempt_count=?1,next_attempt_at=NULL,last_error=?2,
1124 failure_retryable=?3,transcription_status_json=?4,updated_at=?5
1125 WHERE id=?6",
1126 params![
1127 attempts,
1128 concise(message, 2_000),
1129 i64::from(retryable),
1130 progress
1131 .map(without_results)
1132 .map(|progress| serde_json::to_string(&progress))
1133 .transpose()?,
1134 Utc::now().to_rfc3339(),
1135 id.to_string()
1136 ],
1137 )?;
1138 tracing::error!(recording_id=%id, attempts, retryable, "Audio transcription stopped");
1139 Ok(())
1140}
1141
1142fn persist_progress(inner: &Inner, id: Uuid, snapshot: &TranscriptionStatus) -> anyhow::Result<()> {
1143 let durable_status = transcription_stage(snapshot);
1144 let serialized = serde_json::to_string(&without_results(snapshot.clone()))?;
1145 let db = inner
1146 .db
1147 .lock()
1148 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1149 db.execute(
1150 "UPDATE audio_recordings
1151 SET status=?1,transcription_status_json=?2,updated_at=?3
1152 WHERE id=?4 AND (status<>?1 OR COALESCE(transcription_status_json,'')<>?2)",
1153 params![
1154 durable_status,
1155 serialized,
1156 Utc::now().to_rfc3339(),
1157 id.to_string()
1158 ],
1159 )?;
1160 Ok(())
1161}
1162
1163fn load_cached_transcript_piece(
1164 db: &Mutex<Connection>,
1165 recording_id: Uuid,
1166 plan: ChunkPlan,
1167 classification: &ClassificationContext,
1168) -> anyhow::Result<Option<ChunkTranscript>> {
1169 ensure!(
1170 recording_id == classification.recording_id,
1171 "cache classification context belongs to another recording"
1172 );
1173 let piece_index = i64::try_from(plan.index).context("piece index exceeds SQLite limits")?;
1174 let piece_count = i64::try_from(plan.total).context("piece count exceeds SQLite limits")?;
1175 let audio_start_ms =
1176 i64::try_from(plan.start_ms).context("piece start exceeds SQLite limits")?;
1177 let audio_end_ms = i64::try_from(plan.end_ms).context("piece end exceeds SQLite limits")?;
1178 let stored = {
1179 let db = db
1180 .lock()
1181 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1182 db.query_row(
1183 "SELECT raw_gemini_response,parsed_json
1184 FROM audio_transcript_pieces
1185 WHERE recording_id=?1
1186 AND cache_revision=?2
1187 AND piece_index=?3
1188 AND piece_count=?4
1189 AND audio_start_ms=?5
1190 AND audio_end_ms=?6
1191 ORDER BY datetime(created_at) DESC,attempt_id DESC
1192 LIMIT 1",
1193 params![
1194 recording_id.to_string(),
1195 PIECE_CACHE_REVISION,
1196 piece_index,
1197 piece_count,
1198 audio_start_ms,
1199 audio_end_ms,
1200 ],
1201 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
1202 )
1203 .optional()?
1204 };
1205 let Some((raw_gemini_response, parsed_json)) = stored else {
1206 return Ok(None);
1207 };
1208 ensure!(
1209 !raw_gemini_response.trim().is_empty(),
1210 "cached piece omitted its raw Gemini response"
1211 );
1212 let duration_seconds = (plan.end_ms - plan.start_ms) as f64 / 1_000.0;
1213 let mut parsed: ParsedChunk = serde_json::from_str(&parsed_json)
1214 .context("cached parsed speaker analysis is not valid JSON")?;
1215 validate_parsed_chunk(&mut parsed, duration_seconds)
1216 .context("cached parsed speaker analysis is invalid")?;
1217 let (observations, clean) = classify_speakers(classification, plan.index, &parsed)
1218 .context("reclassifying cached speaker rows failed")?;
1219 Ok(Some(ChunkTranscript {
1220 plan,
1221 raw_gemini_response,
1222 parsed,
1223 observations,
1224 clean,
1225 }))
1226}
1227
1228fn persist_transcript_piece(
1229 db: &Mutex<Connection>,
1230 recording_id: Uuid,
1231 attempt_id: &str,
1232 piece: &ChunkTranscript,
1233) -> anyhow::Result<()> {
1234 ensure!(!attempt_id.is_empty(), "piece attempt identity is empty");
1235 let piece_index =
1236 i64::try_from(piece.plan.index).context("piece index exceeds SQLite limits")?;
1237 let piece_count =
1238 i64::try_from(piece.plan.total).context("piece count exceeds SQLite limits")?;
1239 let audio_start_ms =
1240 i64::try_from(piece.plan.start_ms).context("piece start exceeds SQLite limits")?;
1241 let audio_end_ms =
1242 i64::try_from(piece.plan.end_ms).context("piece end exceeds SQLite limits")?;
1243 let parsed_json = serde_json::to_string(&piece.parsed)?;
1244 let db = db
1245 .lock()
1246 .map_err(|_| anyhow::anyhow!("AudioIngress database lock was poisoned"))?;
1247 db.execute(
1248 "INSERT INTO audio_transcript_pieces(
1249 recording_id,attempt_id,cache_revision,piece_index,piece_count,
1250 audio_start_ms,audio_end_ms,transcript_json,raw_gemini_response,
1251 parsed_json,created_at
1252 ) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?8,?10)
1253 ON CONFLICT(recording_id,attempt_id,piece_index) DO UPDATE SET
1254 cache_revision=excluded.cache_revision,
1255 piece_count=excluded.piece_count,
1256 audio_start_ms=excluded.audio_start_ms,
1257 audio_end_ms=excluded.audio_end_ms,
1258 transcript_json=excluded.transcript_json,
1259 raw_gemini_response=excluded.raw_gemini_response,
1260 parsed_json=excluded.parsed_json",
1261 params![
1262 recording_id.to_string(),
1263 attempt_id,
1264 PIECE_CACHE_REVISION,
1265 piece_index,
1266 piece_count,
1267 audio_start_ms,
1268 audio_end_ms,
1269 parsed_json,
1270 piece.raw_gemini_response,
1271 Utc::now().to_rfc3339(),
1272 ],
1273 )?;
1274 Ok(())
1275}
1276
1277fn remove_job(inner: &Inner, id: Uuid) -> anyhow::Result<()> {
1278 inner
1279 .jobs
1280 .lock()
1281 .map_err(|_| anyhow::anyhow!("audio transcription job lock was poisoned"))?
1282 .remove(&id);
1283 Ok(())
1284}
1285
1286fn transcription_stage(snapshot: &TranscriptionStatus) -> &'static str {
1287 let plan_complete = snapshot
1288 .steps
1289 .iter()
1290 .any(|entry| entry.step == Step::PlanChunks && entry.state == StepState::Completed);
1291 if !plan_complete {
1292 return "chunking";
1293 }
1294 let chunks_complete = snapshot
1295 .steps
1296 .iter()
1297 .filter(|entry| {
1298 matches!(
1299 entry.step,
1300 Step::TranscribeChunk { .. } | Step::ParseChunk { .. }
1301 )
1302 })
1303 .all(|entry| entry.state == StepState::Completed);
1304 if chunks_complete {
1305 "reconciling"
1306 } else {
1307 "transcribing"
1308 }
1309}
1310
1311fn without_results(mut status: TranscriptionStatus) -> TranscriptionStatus {
1312 status.transcript = None;
1313 status.correction_packet = None;
1314 status
1315}
1316
1317fn initial_progress() -> TranscriptionStatus {
1318 TranscriptionStatus {
1319 state: JobState::Queued,
1320 steps: Vec::new(),
1321 transcript: None,
1322 correction_packet: None,
1323 }
1324}
1325
1326fn recover_interrupted_attempts(connection: &Connection) -> rusqlite::Result<()> {
1327 connection.execute(
1328 "UPDATE audio_recordings
1329 SET status=CASE WHEN attempt_count>=?1 THEN 'failed' ELSE 'uploaded' END,
1330 next_attempt_at=NULL,
1331 last_error=CASE WHEN attempt_count>=?1
1332 THEN 'Audio transcription stopped after its fifth attempt was interrupted.'
1333 ELSE 'Audio transcription was interrupted and will restart automatically.'
1334 END,
1335 failure_retryable=1,
1336 updated_at=?2
1337 WHERE status IN ('chunking','transcribing','reconciling')",
1338 params![FAILURE_LIMIT, Utc::now().to_rfc3339()],
1339 )?;
1340 Ok(())
1341}
1342
1343fn apply_migrations(connection: &Connection) -> anyhow::Result<()> {
1344 let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?;
1345 ensure!(
1346 version <= LATEST_SCHEMA_VERSION,
1347 "audio-ingress database schema version {version} is newer than supported version {LATEST_SCHEMA_VERSION}"
1348 );
1349 if version == 0 {
1350 let has_recordings = connection.query_row(
1351 "SELECT EXISTS(
1352 SELECT 1 FROM sqlite_schema
1353 WHERE type='table' AND name='audio_recordings'
1354 )",
1355 [],
1356 |row| row.get::<_, i64>(0),
1357 )? == 1;
1358 if !has_recordings {
1359 connection.execute_batch(FRESH_SCHEMA)?;
1360 return Ok(());
1361 }
1362 }
1363 if version < 1 {
1364 connection.execute_batch(INITIAL_MIGRATION)?;
1365 }
1366 if version < 2 {
1367 connection.execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)?;
1368 }
1369 if version < 3 {
1370 connection.execute_batch(TRANSCRIPTION_STATUS_MIGRATION)?;
1371 }
1372 if version < 4 {
1373 connection.execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)?;
1374 }
1375 if version < 5 {
1376 connection.execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)?;
1377 }
1378 if version < 6 {
1379 connection.execute_batch(STANDALONE_LIBRARY_MIGRATION)?;
1380 }
1381 if version < 7 {
1382 connection.execute_batch(DURABLE_TRANSCRIPT_PIECES_MIGRATION)?;
1383 }
1384 if version < 8 {
1385 connection.execute_batch(UNIQUE_TRANSCRIPTION_ATTEMPTS_MIGRATION)?;
1386 }
1387 if version < 9 {
1388 connection.execute_batch(USAGE_USER_MIGRATION)?;
1389 }
1390 if version < 10 {
1391 connection.execute_batch(SPEAKER_CORRECTION_PACKETS_MIGRATION)?;
1392 }
1393 Ok(())
1394}
1395
1396fn ensure_private_directory(path: &Path) -> anyhow::Result<()> {
1397 fs::create_dir_all(path).with_context(|| format!("creating {}", path.display()))?;
1398 #[cfg(unix)]
1399 {
1400 use std::os::unix::fs::PermissionsExt;
1401 fs::set_permissions(path, fs::Permissions::from_mode(0o700))
1402 .with_context(|| format!("setting private permissions on {}", path.display()))?;
1403 }
1404 Ok(())
1405}
1406
1407fn set_private_file(path: &Path) -> anyhow::Result<()> {
1408 #[cfg(unix)]
1409 {
1410 use std::os::unix::fs::PermissionsExt;
1411 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
1412 .with_context(|| format!("setting private permissions on {}", path.display()))?;
1413 }
1414 Ok(())
1415}
1416
1417fn sync_file(path: &Path) -> anyhow::Result<()> {
1418 fs::OpenOptions::new()
1419 .read(true)
1420 .write(true)
1421 .open(path)
1422 .with_context(|| format!("opening {} for sync", path.display()))?
1423 .sync_all()
1424 .with_context(|| format!("syncing {}", path.display()))
1425}
1426
1427fn sync_directory(path: &Path) -> anyhow::Result<()> {
1428 #[cfg(unix)]
1429 fs::File::open(path)
1430 .with_context(|| format!("opening directory {} for sync", path.display()))?
1431 .sync_all()
1432 .with_context(|| format!("syncing directory {}", path.display()))?;
1433 Ok(())
1434}
1435
1436fn safe_filename(value: Option<&str>) -> String {
1437 let name = value
1438 .and_then(|value| Path::new(value).file_name())
1439 .and_then(|value| value.to_str())
1440 .unwrap_or("audio.wav");
1441 let clean = name
1442 .chars()
1443 .map(|character| {
1444 if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
1445 character
1446 } else {
1447 '_'
1448 }
1449 })
1450 .take(200)
1451 .collect::<String>();
1452 if clean.is_empty() {
1453 "audio.wav".into()
1454 } else {
1455 clean
1456 }
1457}
1458
1459fn concise(value: &str, limit: usize) -> String {
1460 let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
1461 let bounded = normalized.chars().take(limit).collect::<String>();
1462 if bounded.is_empty() {
1463 "Audio transcription failed without an error message.".into()
1464 } else {
1465 bounded
1466 }
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471 use super::*;
1472 use std::sync::atomic::{AtomicU64, Ordering};
1473
1474 static NEXT_PATH: AtomicU64 = AtomicU64::new(0);
1475
1476 fn database() -> Connection {
1477 let connection = Connection::open_in_memory().unwrap();
1478 apply_migrations(&connection).unwrap();
1479 connection
1480 }
1481
1482 fn classifier_path(label: &str) -> PathBuf {
1483 std::env::temp_dir().join(format!(
1484 "kcode-audio-ingress-lib-{}-{label}-{}.sqlite3",
1485 std::process::id(),
1486 NEXT_PATH.fetch_add(1, Ordering::Relaxed)
1487 ))
1488 }
1489
1490 fn root_path(label: &str) -> PathBuf {
1491 std::env::temp_dir().join(format!(
1492 "kcode-audio-ingress-lib-{}-{label}-{}",
1493 std::process::id(),
1494 NEXT_PATH.fetch_add(1, Ordering::Relaxed)
1495 ))
1496 }
1497
1498 fn remove_database(path: &Path) {
1499 for suffix in ["", "-wal", "-shm"] {
1500 let mut value = path.as_os_str().to_os_string();
1501 value.push(suffix);
1502 let _ = fs::remove_file(PathBuf::from(value));
1503 }
1504 }
1505
1506 fn transcriber() -> AudioTranscriber {
1507 let audio: AudioChunkCall = Arc::new(|_| {
1508 Box::pin(async { Err(IntelligenceError::new("unexpected audio call", false)) })
1509 });
1510 let text: TextGenerationCall = Arc::new(|_| {
1511 Box::pin(async { Err(IntelligenceError::new("unexpected text call", false)) })
1512 });
1513 AudioTranscriber::new(audio, text)
1514 }
1515
1516 fn insert_recording(connection: &Connection, id: Uuid, status: &str) {
1517 let has_user_id = connection
1518 .prepare("SELECT 1 FROM pragma_table_info('audio_recordings') WHERE name='user_id'")
1519 .unwrap()
1520 .exists([])
1521 .unwrap();
1522 let sql = if has_user_id {
1523 "INSERT INTO audio_recordings(
1524 id,user_id,sha256,original_filename,content_type,size_bytes,source_created_at,
1525 received_at,updated_at,original_relative_path,status,gemini_model,
1526 reconciliation_model,reconciliation_reasoning
1527 ) VALUES(?1,'test-user',?2,'note.wav','audio/wav',4,?3,?3,?3,?4,?5,?6,?7,?8)"
1528 } else {
1529 "INSERT INTO audio_recordings(
1530 id,sha256,original_filename,content_type,size_bytes,source_created_at,
1531 received_at,updated_at,original_relative_path,status,gemini_model,
1532 reconciliation_model,reconciliation_reasoning
1533 ) VALUES(?1,?2,'note.wav','audio/wav',4,?3,?3,?3,?4,?5,?6,?7,?8)"
1534 };
1535 connection
1536 .execute(
1537 sql,
1538 params![
1539 id.to_string(),
1540 format!("{:064x}", 1),
1541 "2026-01-01T00:00:00Z",
1542 format!("originals/{id}.wav"),
1543 status,
1544 TRANSCRIPTION_MODEL,
1545 RECONCILIATION_MODEL,
1546 RECONCILIATION_REASONING,
1547 ],
1548 )
1549 .unwrap();
1550 }
1551
1552 fn sample_row() -> FeatureRow {
1553 FeatureRow::new(std::array::from_fn(|index| {
1554 u8::try_from(index * 3 + 10).unwrap()
1555 }))
1556 .unwrap()
1557 }
1558
1559 fn legacy_feature_row() -> Value {
1560 serde_json::json!({
1561 "accent_variety":"general_american",
1562 "articulation_rate_syllables_per_second":4.1,
1563 "breathiness":0.1,
1564 "cefr":"c2",
1565 "consonant_cluster_reduction_percent":0.0,
1566 "creaky_phonation_percent":0.0,
1567 "f0_pitch_span_semitones":8.0,
1568 "filled_pauses_per_100_words":1.0,
1569 "foreign_accentedness":0.0,
1570 "formant_dispersion_hz":1100.0,
1571 "hypernasality":0.0,
1572 "lateral_realization":"standard",
1573 "lexical_stress_accuracy_percent":100.0,
1574 "median_f0_hz":150.0,
1575 "monophthongization_percent":0.0,
1576 "npvi_v":50.0,
1577 "perceived_age":40.0,
1578 "rhotic_realization":"rhotic",
1579 "roughness":0.1,
1580 "s_realization":"standard",
1581 "unstressed_vowel_reduction_percent":50.0,
1582 "vai":1.0,
1583 "vocal_gender_presentation":50.0,
1584 "word_initial_stressed_prevocalic_t_vot_ms":60.0
1585 })
1586 }
1587
1588 fn legacy_packet_json(id: Uuid) -> String {
1589 let packet = CorrectionPacket {
1590 recording_id: id,
1591 user_id: "test-user".into(),
1592 sha256: format!("{:064x}", 1),
1593 original_filename: "note.wav".into(),
1594 size_bytes: 4,
1595 recorded_at: DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1596 .unwrap()
1597 .with_timezone(&Utc),
1598 clean: false,
1599 chunk_count: 1,
1600 chunks: vec![CorrectionChunk {
1601 chunk_index: 0,
1602 chunk_count: 1,
1603 audio_start_ms: 0,
1604 audio_end_ms: 1_000,
1605 raw_gemini_response: "legacy raw response".into(),
1606 parsed: sample_parsed(),
1607 observations: vec![CorrectionObservation {
1608 local_label: "Speaker A".into(),
1609 speaker_ordinal: 0,
1610 observation_key: ObservationKey {
1611 object_id: format!("kcode-audio-ingress/recording/{id}/chunk/0"),
1612 piece_index: 0,
1613 },
1614 candidate: None,
1615 identified_full_name: None,
1616 confirmed_full_name: None,
1617 }],
1618 clean: false,
1619 }],
1620 confirmation_state: ConfirmationState::Unconfirmed,
1621 };
1622 let mut value = serde_json::to_value(packet).unwrap();
1623 value["chunks"][0]["parsed"]["speakers"][0]["feature_row"] = legacy_feature_row();
1624 serde_json::to_string(&value).unwrap()
1625 }
1626
1627 fn sample_parsed() -> ParsedChunk {
1628 ParsedChunk {
1629 utterances: vec![ParsedUtterance {
1630 speaker: "Speaker A".into(),
1631 language: "eng".into(),
1632 original_text: "Hello.".into(),
1633 english_translation: String::new(),
1634 corrected_natural_text: None,
1635 coaching: Vec::new(),
1636 annotations: Vec::new(),
1637 }],
1638 notes: vec!["Clear recording.".into()],
1639 clip_valid: true,
1640 clip_validity_reason: None,
1641 speakers: vec![ParsedSpeaker {
1642 local_label: "Speaker A".into(),
1643 primary_language: Some("eng".into()),
1644 feature_row: Some(sample_row()),
1645 }],
1646 }
1647 }
1648
1649 fn sample_piece() -> ChunkTranscript {
1650 ChunkTranscript {
1651 plan: ChunkPlan {
1652 index: 0,
1653 total: 2,
1654 start_ms: 0,
1655 end_ms: 120_000,
1656 },
1657 raw_gemini_response: "complete raw Gemini response".into(),
1658 parsed: sample_parsed(),
1659 observations: Vec::new(),
1660 clean: false,
1661 }
1662 }
1663
1664 fn classification_context(
1665 recording_id: Uuid,
1666 classifier: Arc<SpeechClassifier>,
1667 ) -> ClassificationContext {
1668 ClassificationContext {
1669 recording_id,
1670 user_id: "test-user".into(),
1671 sha256: format!("{:064x}", 1),
1672 original_filename: "note.wav".into(),
1673 size_bytes: 4,
1674 recorded_at: DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1675 .unwrap()
1676 .with_timezone(&Utc),
1677 classifier,
1678 }
1679 }
1680
1681 #[test]
1682 fn fresh_schema_contains_recordings_durable_pieces_and_packet_columns() {
1683 let connection = database();
1684 let tables = connection
1685 .prepare(
1686 "SELECT name FROM sqlite_schema
1687 WHERE type='table' AND name NOT LIKE 'sqlite_%'
1688 ORDER BY name",
1689 )
1690 .unwrap()
1691 .query_map([], |row| row.get::<_, String>(0))
1692 .unwrap()
1693 .collect::<Result<Vec<_>, _>>()
1694 .unwrap();
1695 let version: i64 = connection
1696 .query_row("PRAGMA user_version", [], |row| row.get(0))
1697 .unwrap();
1698 let piece_columns = connection
1699 .prepare("SELECT name FROM pragma_table_info('audio_transcript_pieces')")
1700 .unwrap()
1701 .query_map([], |row| row.get::<_, String>(0))
1702 .unwrap()
1703 .collect::<Result<Vec<_>, _>>()
1704 .unwrap();
1705 let recording_columns = connection
1706 .prepare("SELECT name FROM pragma_table_info('audio_recordings')")
1707 .unwrap()
1708 .query_map([], |row| row.get::<_, String>(0))
1709 .unwrap()
1710 .collect::<Result<Vec<_>, _>>()
1711 .unwrap();
1712 assert_eq!(tables, vec!["audio_recordings", "audio_transcript_pieces"]);
1713 assert!(piece_columns.contains(&"raw_gemini_response".into()));
1714 assert!(piece_columns.contains(&"parsed_json".into()));
1715 assert!(recording_columns.contains(&"correction_packet_json".into()));
1716 assert_eq!(version, LATEST_SCHEMA_VERSION);
1717 }
1718
1719 #[tokio::test]
1720 async fn open_uses_the_injected_classifier_without_creating_a_private_database() {
1721 let root = root_path("shared-classifier-root");
1722 let classifier_path = classifier_path("shared-classifier");
1723 let classifier = Arc::new(SpeechClassifier::open(&classifier_path).unwrap());
1724
1725 let ingress = AudioIngress::open(&root, transcriber(), classifier.clone())
1726 .await
1727 .unwrap();
1728
1729 assert!(Arc::ptr_eq(&ingress.inner.classifier, &classifier));
1730 assert!(!root.join("speaker-classification.sqlite3").exists());
1731
1732 drop(ingress);
1733 tokio::task::yield_now().await;
1734 let _ = fs::remove_dir_all(&root);
1735 drop(classifier);
1736 remove_database(&classifier_path);
1737 }
1738
1739 #[test]
1740 fn version_five_databases_upgrade_without_losing_legacy_queue_data() {
1741 let connection = Connection::open_in_memory().unwrap();
1742 connection.execute_batch(INITIAL_MIGRATION).unwrap();
1743 connection
1744 .execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)
1745 .unwrap();
1746 connection
1747 .execute_batch(TRANSCRIPTION_STATUS_MIGRATION)
1748 .unwrap();
1749 connection
1750 .execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)
1751 .unwrap();
1752 connection
1753 .execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)
1754 .unwrap();
1755 let id = Uuid::new_v4();
1756 insert_recording(&connection, id, "failed");
1757
1758 apply_migrations(&connection).unwrap();
1759
1760 let version: i64 = connection
1761 .query_row("PRAGMA user_version", [], |row| row.get(0))
1762 .unwrap();
1763 let retryable: i64 = connection
1764 .query_row(
1765 "SELECT failure_retryable FROM audio_recordings WHERE id=?1",
1766 [id.to_string()],
1767 |row| row.get(0),
1768 )
1769 .unwrap();
1770 let legacy_queue_exists: i64 = connection
1771 .query_row(
1772 "SELECT EXISTS(
1773 SELECT 1 FROM sqlite_schema
1774 WHERE type='table' AND name='audio_ingress_pieces'
1775 )",
1776 [],
1777 |row| row.get(0),
1778 )
1779 .unwrap();
1780 assert_eq!(version, LATEST_SCHEMA_VERSION);
1781 assert_eq!(retryable, 1);
1782 assert_eq!(legacy_queue_exists, 1);
1783 }
1784
1785 #[test]
1786 fn version_seven_piece_rows_migrate_but_cannot_hit_the_new_cache() {
1787 let connection = Connection::open_in_memory().unwrap();
1788 connection.execute_batch(INITIAL_MIGRATION).unwrap();
1789 connection
1790 .execute_batch(RELEASE_DEFERRED_INGRESS_MIGRATION)
1791 .unwrap();
1792 connection
1793 .execute_batch(TRANSCRIPTION_STATUS_MIGRATION)
1794 .unwrap();
1795 connection
1796 .execute_batch(RETRY_ROUNDED_WAV_INTERVALS_MIGRATION)
1797 .unwrap();
1798 connection
1799 .execute_batch(UNIFIED_INGRESS_QUEUE_MIGRATION)
1800 .unwrap();
1801 connection
1802 .execute_batch(STANDALONE_LIBRARY_MIGRATION)
1803 .unwrap();
1804 connection
1805 .execute_batch(DURABLE_TRANSCRIPT_PIECES_MIGRATION)
1806 .unwrap();
1807 let id = Uuid::new_v4();
1808 insert_recording(&connection, id, "transcribing");
1809 connection
1810 .execute(
1811 "INSERT INTO audio_transcript_pieces(
1812 recording_id,attempt,piece_index,piece_count,audio_start_ms,
1813 audio_end_ms,transcript_json,created_at
1814 ) VALUES(?1,1,0,2,0,120000,?2,?3)",
1815 params![
1816 id.to_string(),
1817 r#"{"utterances":[{"original_text":"hello"}]}"#,
1818 "2026-01-01T00:00:00Z",
1819 ],
1820 )
1821 .unwrap();
1822
1823 apply_migrations(&connection).unwrap();
1824
1825 let migrated: (String, String, String, String) = connection
1826 .query_row(
1827 "SELECT attempt_id,cache_revision,raw_gemini_response,parsed_json
1828 FROM audio_transcript_pieces
1829 WHERE recording_id=?1 AND piece_index=0",
1830 [id.to_string()],
1831 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
1832 )
1833 .unwrap();
1834 assert_eq!(migrated.0, "version-7-attempt-1");
1835 assert_eq!(migrated.1, "gemini-3.1-pro-transcription-v1");
1836 assert_ne!(migrated.1, PIECE_CACHE_REVISION);
1837 assert_eq!(migrated.2, "");
1838 assert_eq!(migrated.3, "");
1839 }
1840
1841 #[test]
1842 fn status_exposes_a_completed_correction_packet() {
1843 let connection = database();
1844 let id = Uuid::new_v4();
1845 insert_recording(&connection, id, "ready_for_ingress");
1846 let packet = CorrectionPacket {
1847 recording_id: id,
1848 user_id: "test-user".into(),
1849 sha256: format!("{:064x}", 1),
1850 original_filename: "note.wav".into(),
1851 size_bytes: 4,
1852 recorded_at: DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1853 .unwrap()
1854 .with_timezone(&Utc),
1855 clean: false,
1856 chunk_count: 1,
1857 chunks: Vec::new(),
1858 confirmation_state: ConfirmationState::Unconfirmed,
1859 };
1860 connection
1861 .execute(
1862 "UPDATE audio_recordings
1863 SET final_transcript='hello',correction_packet_json=?1 WHERE id=?2",
1864 params![serde_json::to_string(&packet).unwrap(), id.to_string()],
1865 )
1866 .unwrap();
1867 let status = connection
1868 .query_row(
1869 "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,received_at,
1870 status,gemini_model,reconciliation_model,reconciliation_reasoning,
1871 attempt_count,last_error,failure_retryable,transcription_status_json,
1872 final_transcript,correction_packet_json
1873 FROM audio_recordings WHERE id=?1",
1874 [id.to_string()],
1875 row_recording_status,
1876 )
1877 .unwrap();
1878 assert!(matches!(
1879 status.state,
1880 RecordingState::Complete { ref transcript } if transcript == "hello"
1881 ));
1882 assert_eq!(status.correction_packet.unwrap().recording_id, id);
1883 }
1884
1885 #[test]
1886 fn legacy_correction_packets_remain_reviewable_without_translating_features() {
1887 let connection = database();
1888 let id = Uuid::new_v4();
1889 insert_recording(&connection, id, "ready_for_ingress");
1890 let serialized = legacy_packet_json(id);
1891 connection
1892 .execute(
1893 "UPDATE audio_recordings
1894 SET final_transcript='hello',correction_packet_json=?1 WHERE id=?2",
1895 params![serialized, id.to_string()],
1896 )
1897 .unwrap();
1898
1899 let status = connection
1900 .query_row(
1901 "SELECT id,user_id,sha256,original_filename,size_bytes,source_created_at,received_at,
1902 status,gemini_model,reconciliation_model,reconciliation_reasoning,
1903 attempt_count,last_error,failure_retryable,transcription_status_json,
1904 final_transcript,correction_packet_json
1905 FROM audio_recordings WHERE id=?1",
1906 [id.to_string()],
1907 row_recording_status,
1908 )
1909 .unwrap();
1910 let packet = status.correction_packet.unwrap();
1911 assert_eq!(packet.confirmation_state, ConfirmationState::Unconfirmed);
1912 assert_eq!(packet.chunks[0].observations.len(), 1);
1913 assert!(packet.chunks[0].parsed.speakers[0].feature_row.is_none());
1914 }
1915
1916 #[test]
1917 fn legacy_confirmation_preserves_obsolete_features_without_training_them() {
1918 use kcode_speaker_system::DeleteOutcome;
1919
1920 let id = Uuid::new_v4();
1921 let original = legacy_packet_json(id);
1922 let mut stored = StoredCorrectionPacket::decode(&original).unwrap();
1923 let keys = stored.legacy_observation_keys().unwrap();
1924 let observation_key = stored.packet.chunks[0].observations[0]
1925 .observation_key
1926 .clone();
1927 let confirmation = RecordingConfirmation {
1928 recording_id: id,
1929 observations: vec![ObservationConfirmation {
1930 observation_key: observation_key.clone(),
1931 confirmed_full_name: "David Example".into(),
1932 }],
1933 };
1934 let path = classifier_path("legacy-confirmation");
1935 let classifier = SpeechClassifier::open(&path).unwrap();
1936
1937 apply_confirmations(&classifier, &mut stored.packet, &confirmation, &keys).unwrap();
1938 let encoded = stored.encode().unwrap();
1939 let encoded_value: Value = serde_json::from_str(&encoded).unwrap();
1940 assert_eq!(
1941 encoded_value.pointer("/chunks/0/parsed/speakers/0/feature_row"),
1942 Some(&legacy_feature_row())
1943 );
1944 let decoded = StoredCorrectionPacket::decode(&encoded).unwrap().packet;
1945 assert_eq!(decoded.confirmation_state, ConfirmationState::Confirmed);
1946 assert_eq!(
1947 decoded.chunks[0].observations[0]
1948 .confirmed_full_name
1949 .as_deref(),
1950 Some("David Example")
1951 );
1952 assert_eq!(
1953 classifier.delete(observation_key).unwrap(),
1954 DeleteOutcome::NotFound
1955 );
1956
1957 drop(classifier);
1958 remove_database(&path);
1959 }
1960
1961 #[test]
1962 fn arbitrary_feature_objects_are_not_treated_as_legacy_packets() {
1963 let id = Uuid::new_v4();
1964 let mut value: Value = serde_json::from_str(&legacy_packet_json(id)).unwrap();
1965 value["chunks"][0]["parsed"]["speakers"][0]["feature_row"] =
1966 serde_json::json!({"unknown":1});
1967 assert!(StoredCorrectionPacket::decode(&value.to_string()).is_err());
1968 }
1969
1970 #[test]
1971 fn transcript_pieces_preserve_raw_and_parsed_data_attempt_scoped() {
1972 let connection = database();
1973 let id = Uuid::new_v4();
1974 insert_recording(&connection, id, "transcribing");
1975 let db = Mutex::new(connection);
1976 let piece = sample_piece();
1977 persist_transcript_piece(&db, id, "attempt-a", &piece).unwrap();
1978 persist_transcript_piece(&db, id, "attempt-b", &piece).unwrap();
1979
1980 let db = db.lock().unwrap();
1981 let rows: i64 = db
1982 .query_row(
1983 "SELECT COUNT(*) FROM audio_transcript_pieces WHERE recording_id=?1",
1984 [id.to_string()],
1985 |row| row.get(0),
1986 )
1987 .unwrap();
1988 let stored: (String, String) = db
1989 .query_row(
1990 "SELECT raw_gemini_response,parsed_json
1991 FROM audio_transcript_pieces
1992 WHERE recording_id=?1 AND attempt_id='attempt-a' AND piece_index=0",
1993 [id.to_string()],
1994 |row| Ok((row.get(0)?, row.get(1)?)),
1995 )
1996 .unwrap();
1997 assert_eq!(rows, 2);
1998 assert_eq!(stored.0, piece.raw_gemini_response);
1999 assert_eq!(
2000 serde_json::from_str::<ParsedChunk>(&stored.1).unwrap(),
2001 piece.parsed
2002 );
2003 }
2004
2005 #[test]
2006 fn cache_reuses_only_exact_matching_new_revision_piece_plans() {
2007 let connection = database();
2008 let id = Uuid::new_v4();
2009 insert_recording(&connection, id, "transcribing");
2010 let db = Mutex::new(connection);
2011 let piece = sample_piece();
2012 persist_transcript_piece(&db, id, "attempt-a", &piece).unwrap();
2013
2014 let path = classifier_path("cache");
2015 let classifier = Arc::new(SpeechClassifier::open(&path).unwrap());
2016 let context = classification_context(id, classifier);
2017 let exact = load_cached_transcript_piece(&db, id, piece.plan, &context).unwrap();
2018 let changed = load_cached_transcript_piece(
2019 &db,
2020 id,
2021 ChunkPlan {
2022 end_ms: piece.plan.end_ms + 1,
2023 ..piece.plan
2024 },
2025 &context,
2026 )
2027 .unwrap();
2028
2029 assert_eq!(
2030 exact.unwrap().raw_gemini_response,
2031 piece.raw_gemini_response
2032 );
2033 assert!(changed.is_none());
2034 drop(context);
2035 remove_database(&path);
2036 }
2037
2038 #[test]
2039 fn future_schema_versions_are_rejected() {
2040 let connection = Connection::open_in_memory().unwrap();
2041 connection
2042 .execute_batch("PRAGMA user_version = 11;")
2043 .unwrap();
2044 let error = apply_migrations(&connection).unwrap_err().to_string();
2045 assert!(error.contains("newer than supported"));
2046 }
2047
2048 #[test]
2049 fn interrupted_attempts_consume_the_fixed_budget() {
2050 let connection = database();
2051 let id = Uuid::new_v4();
2052 insert_recording(&connection, id, "transcribing");
2053 connection
2054 .execute(
2055 "UPDATE audio_recordings SET attempt_count=5 WHERE id=?1",
2056 [id.to_string()],
2057 )
2058 .unwrap();
2059 recover_interrupted_attempts(&connection).unwrap();
2060 let state: String = connection
2061 .query_row(
2062 "SELECT status FROM audio_recordings WHERE id=?1",
2063 [id.to_string()],
2064 |row| row.get(0),
2065 )
2066 .unwrap();
2067 assert_eq!(state, "failed");
2068 }
2069
2070 #[test]
2071 fn filenames_cannot_escape_the_persistence_root() {
2072 assert_eq!(safe_filename(Some("../../secret.wav")), "secret.wav");
2073 assert_eq!(safe_filename(Some("meeting note.wav")), "meeting_note.wav");
2074 assert_eq!(safe_filename(None), "audio.wav");
2075 }
2076}