feat(import): import_media — file/URL to a transcribed, diarized meeting; auto_record default
This commit is contained in:
@@ -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, ¬es_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.
|
||||
|
||||
Reference in New Issue
Block a user