feat(import): import_media — file/URL to a transcribed, diarized meeting; auto_record default

This commit is contained in:
iamdoubz
2026-07-11 00:38:19 -05:00
parent 1ea8011dc8
commit fd556c57a1
+168
View File
@@ -76,6 +76,7 @@ fn default_settings() -> Settings {
pst_last_path: None,
pst_auto_sync: false,
pst_import_range_days: None,
auto_record_calendar: false,
graph_calendar_enabled: false,
graph_calendar_credential_ref: None,
audio_output_device: None,
@@ -1726,6 +1727,173 @@ pub async fn reprocess_transcript(
Ok(())
}
/// Filename stem of a local path, for a sensible default meeting title; `None`
/// for a URL (yt-dlp's real title isn't fetched — the user can rename).
fn default_title_from_source(source: &str) -> Option<String> {
if crate::media::is_url(source) {
return None;
}
std::path::Path::new(source)
.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.filter(|s| !s.trim().is_empty())
}
/// Manually add a meeting from an existing recording: a local audio/video file
/// or a URL (YouTube/streaming page, or a direct media URL). Shells out to
/// `ffmpeg` (transcode) and — for URLs — `yt-dlp` (both external, not bundled;
/// a missing tool is a clear error). The produced 16kHz-mono WAV becomes the
/// meeting's retained `audio.wav`, then goes through the same
/// transcription + diarization + finalize path as a live recording.
#[tauri::command]
pub async fn import_media(
app: AppHandle,
state: State<'_, AppState>,
source: String,
title: Option<String>,
) -> WaResult<MeetingId> {
let settings = load_settings();
let model_id = model_id_for(&settings);
let backend = backend_for(&settings);
let model_path = whisper_model_file(&model_id);
if !model_path.exists() {
return Err(WaError::new(
"transcription",
format!(
"whisper model '{model_id}' not found at {}; download it from Settings first",
model_path.display()
),
));
}
let language: Option<String> =
normalize_language(settings.whisper_language.as_deref()).map(|s| s.to_string());
let title = title
.filter(|t| !t.trim().is_empty())
.or_else(|| default_title_from_source(&source))
.unwrap_or_else(|| "Imported meeting".to_string());
let meeting_id = state
.store
.create_meeting(NewMeeting {
title,
calendar_event_id: None,
template_id: None,
language: language.clone(),
})
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let dir = meeting_dir(&meeting_id);
let wav_path = dir.join("audio.wav");
// Transcode into the meeting's audio.wav off the async runtime (shells out
// to ffmpeg/yt-dlp). On any failure, drop the empty meeting so a bad import
// doesn't leave a husk row behind.
let transcode = tauri::async_runtime::spawn_blocking({
let source = source.clone();
let wav_path = wav_path.clone();
let dir = dir.clone();
move || {
let work = dir.join("import-tmp");
std::fs::create_dir_all(&work)?;
let r = crate::media::import_to_wav(&source, &wav_path, &work);
let _ = std::fs::remove_dir_all(&work);
r
}
})
.await
.map_err(|e| WaError::new("import", e.to_string()))?;
if let Err(e) = transcode {
let _ = state.store.delete_meeting(&meeting_id).await;
return Err(WaError::new("import", e.to_string()));
}
// Transcribe the produced WAV (same batch path as reprocess_transcript).
let (mut segments, resolved_language) = tauri::async_runtime::spawn_blocking({
let wav_path = wav_path.clone();
let model_path = model_path.clone();
let requested_language = language.clone();
move || {
let (transcriber, _used) =
load_transcriber(backend, &model_path, requested_language.as_deref())?;
let segments = transcriber.transcribe_file(&wav_path)?;
let resolved = transcriber
.detected_language()
.or_else(|| transcriber.effective_language());
Ok::<_, crate::transcription::TrxError>((segments, resolved))
}
})
.await
.map_err(|e| WaError::new("transcription", e.to_string()))?
.map_err(|e| WaError::new("transcription", e.to_string()))?;
// One diarization pass if the models are installed, exactly like
// stop_recording — otherwise every line stays the single "S1" placeholder.
let diarizer: Option<Arc<dyn Diarizer>> =
tauri::async_runtime::spawn_blocking(diarizer_from_installed_models)
.await
.ok()
.flatten()
.map(|d| Arc::new(d) as Arc<dyn Diarizer>);
if let Some(diarizer) = diarizer {
let diarizer_for_task = diarizer.clone();
let wp = wav_path.clone();
match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wp)).await {
Ok(Ok(spans)) => diarizer.assign(&mut segments, &spans),
Ok(Err(e)) => tracing::warn!("import diarization pass failed: {e}"),
Err(e) => tracing::warn!("import diarization task failed: {e}"),
}
}
let speakers = speaker_infos_from_segments(&segments, &HashMap::new());
let duration_secs = segments
.last()
.map(|s| (s.end_ms / 1000) as i64)
.unwrap_or(0);
state
.store
.finalize_meeting(
&meeting_id,
FinalizeMeeting {
segments: segments.clone(),
speakers: speakers.clone(),
duration_secs,
recorded: true, // the imported WAV is the recording — keep it
language: resolved_language,
backend_used: Some(backend.as_str().to_string()),
model_used: Some(model_id.clone()),
},
)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let notes_md = crate::notes::MarkdownNotes.merge(
&segments,
&speakers,
&crate::models::ManualNotes::default(),
None,
None,
);
let _ = state.store.update_notes(&meeting_id, &notes_md).await;
// Seal the retained recording at rest when the vault is unlocked (T8.8),
// matching stop_recording so imports aren't left as plaintext outliers.
if crate::vault::is_unlocked() {
if let Ok(raw) = std::fs::read(&wav_path) {
if let Ok(sealed) = crate::vault::seal(&raw) {
let _ = std::fs::write(&wav_path, sealed);
}
}
}
let _ = app.emit(
"transcript://finalized",
serde_json::json!({ "meetingId": meeting_id, "segmentCount": segments.len() }),
);
Ok(meeting_id)
}
/// Re-run transcription from a `recovering` meeting's working `audio.wav`
/// (T2.8, FR-REL-1). CPU-bound, so it runs on a blocking task rather than
/// tying up an async worker.