From fd556c57a15a04f199fc55d569e04da3a90db407 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 00:38:19 -0500 Subject: [PATCH] =?UTF-8?q?feat(import):=20import=5Fmedia=20=E2=80=94=20fi?= =?UTF-8?q?le/URL=20to=20a=20transcribed,=20diarized=20meeting;=20auto=5Fr?= =?UTF-8?q?ecord=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands.rs | 168 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a0059c5..977a35f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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 { + 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, +) -> WaResult { + 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 = + 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> = + tauri::async_runtime::spawn_blocking(diarizer_from_installed_models) + .await + .ok() + .flatten() + .map(|d| Arc::new(d) as Arc); + 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.