mod daemon_ipc; use ffmpeg_sidecar::{ command::{ffmpeg_is_installed, FfmpegCommand}, download::auto_download, event::{FfmpegEvent, LogLevel}, paths::sidecar_path, }; use serde_json::Value; use std::fs; use std::path::PathBuf; /// Get the default output directory for recordings. /// Uses the same directory structure as the record-daemon. fn get_default_output_dir() -> Option { directories::ProjectDirs::from("com", "leaguerecorder", "record-daemon") .map(|dirs| dirs.data_dir().join("recordings")) } /// Get the timeline storage directory. /// Uses the same directory structure as the record-daemon. fn get_timeline_dir() -> PathBuf { get_default_output_dir() .map(|p| p.join("timelines")) .unwrap_or_else(|| PathBuf::from("./recordings/timelines")) } /// Load all timelines from disk as raw JSON values. fn load_timelines() -> Vec { let timeline_dir = get_timeline_dir(); let mut timelines = Vec::new(); if !timeline_dir.exists() { return timelines; } if let Ok(entries) = fs::read_dir(&timeline_dir) { for entry in entries.flatten() { let path = entry.path(); if path.extension().map(|e| e == "json").unwrap_or(false) { if let Ok(contents) = fs::read_to_string(&path) { if let Ok(timeline) = serde_json::from_str::(&contents) { timelines.push(timeline); } } } } } // Sort by start time, newest first timelines.sort_by(|a, b| { let a_time = a.get("start_time").and_then(|v| v.as_str()).unwrap_or(""); let b_time = b.get("start_time").and_then(|v| v.as_str()).unwrap_or(""); b_time.cmp(a_time) }); timelines } /// Get game history - returns full timeline data for each game as raw JSON. #[tauri::command] fn get_game_history() -> Vec { load_timelines() } /// Get a specific timeline by recording ID as raw JSON. #[tauri::command] fn get_timeline(recording_id: String) -> Option { let timeline_dir = get_timeline_dir(); let path = timeline_dir.join(format!("{}.json", recording_id)); if path.exists() { if let Ok(contents) = fs::read_to_string(&path) { return serde_json::from_str::(&contents).ok(); } } None } /// Get the recordings directory path. #[tauri::command] fn get_recordings_dir() -> String { get_default_output_dir() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|| "./recordings".to_string()) } /// Find a video file in the recordings directory. /// Searches for exact filename match or pattern match. #[tauri::command] fn find_video_file(recordings_dir: String, filename: String) -> Option { let recordings_path = PathBuf::from(&recordings_dir); if !recordings_path.exists() { return None; } // Try exact match first let exact_path = recordings_path.join(&filename); if exact_path.exists() { return Some(exact_path.to_string_lossy().to_string()); } // Try to find by pattern (date_time_*.mp4) if let Ok(entries) = fs::read_dir(&recordings_path) { for entry in entries.flatten() { let path = entry.path(); if path.extension().map(|e| e == "mp4" || e == "mkv" || e == "mov").unwrap_or(false) { let file_name = path.file_name()?.to_string_lossy().to_string(); // Check if filename starts with the same date/time pattern // filename format: "2026-03-27_16-42-52_unknown.mp4" // We match the date_time prefix if file_name.starts_with(&filename.replace("_unknown.mp4", "")) { return Some(path.to_string_lossy().to_string()); } } } } None } /// Get list of video files in recordings directory. #[tauri::command] fn list_video_files(recordings_dir: String) -> Vec { let recordings_path = PathBuf::from(&recordings_dir); let mut videos = Vec::new(); if !recordings_path.exists() { return videos; } if let Ok(entries) = fs::read_dir(&recordings_path) { for entry in entries.flatten() { let path = entry.path(); if path.extension().map(|e| e == "mp4" || e == "mkv" || e == "mov").unwrap_or(false) { videos.push(path.to_string_lossy().to_string()); } } } // Sort by modification time, newest first videos.sort_by(|a, b| { let a_time = fs::metadata(a).and_then(|m| m.modified()).ok(); let b_time = fs::metadata(b).and_then(|m| m.modified()).ok(); b_time.cmp(&a_time) }); videos } /// Ensure ffmpeg is available, downloading if necessary. fn ensure_ffmpeg() -> Result<(), String> { if ffmpeg_is_installed() { return Ok(()); } // Auto-download ffmpeg for the current platform auto_download().map_err(|e| format!("Failed to download ffmpeg: {}", e))?; Ok(()) } /// Export a clip from a video using ffmpeg-sidecar (stream copy for speed). #[tauri::command] fn export_clip( video_path: String, start_time: f64, end_time: f64, output_name: String, ) -> Result { // Ensure ffmpeg is available ensure_ffmpeg()?; // Verify input file exists if !PathBuf::from(&video_path).exists() { return Err(format!("Video file not found: {}", video_path)); } // Get output directory let output_dir = get_default_output_dir() .map(|p| p.join("clips")) .unwrap_or_else(|| PathBuf::from("./clips")); // Create clips directory if it doesn't exist if !output_dir.exists() { fs::create_dir_all(&output_dir) .map_err(|e| format!("Failed to create clips directory: {}", e))?; } let output_path = output_dir.join(format!("{}.mp4", output_name)); let duration = end_time - start_time; // Build ffmpeg command with stream copy (fast, no re-encoding) // Note: Put -ss before -i for fast seeking (input seeking) let iter = FfmpegCommand::new() .arg("-y") // Overwrite output file .arg("-ss").arg(&format!("{:.3}", start_time)) .arg("-i").arg(&video_path) .arg("-t").arg(&format!("{:.3}", duration)) .arg("-c").arg("copy") // Stream copy for fast export .arg("-avoid_negative_ts").arg("make_zero") .arg(&output_path.to_string_lossy().to_string()) .spawn() .map_err(|e| format!("Failed to spawn ffmpeg: {}", e))? .iter() .map_err(|e| format!("Failed to create ffmpeg iterator: {}", e))?; // Process the ffmpeg command and collect all errors let mut errors = Vec::new(); for event in iter { match event { FfmpegEvent::Error(e) => { errors.push(format!("FFmpeg error: {}", e)); } FfmpegEvent::Log(LogLevel::Error, msg) => { errors.push(format!("FFmpeg log error: {}", msg)); } FfmpegEvent::Log(LogLevel::Warning, msg) => { // Log warnings but don't fail eprintln!("FFmpeg warning: {}", msg); } _ => {} } } // Check if output file was created if !output_path.exists() { return Err(format!( "Export failed - output file not created. Errors: {}", errors.join("; ") )); } // Check if output file has content let metadata = fs::metadata(&output_path) .map_err(|e| format!("Failed to check output file: {}", e))?; if metadata.len() == 0 { return Err(format!( "Export failed - output file is empty. Errors: {}", errors.join("; ") )); } Ok(output_path.to_string_lossy().to_string()) } /// Export a clip with re-encoding for more precise cuts. #[tauri::command] fn export_clip_precise( video_path: String, start_time: f64, end_time: f64, output_name: String, quality: String, ) -> Result { // Ensure ffmpeg is available ensure_ffmpeg()?; // Verify input file exists if !PathBuf::from(&video_path).exists() { return Err(format!("Video file not found: {}", video_path)); } let output_dir = get_default_output_dir() .map(|p| p.join("clips")) .unwrap_or_else(|| PathBuf::from("./clips")); if !output_dir.exists() { fs::create_dir_all(&output_dir) .map_err(|e| format!("Failed to create clips directory: {}", e))?; } let output_path = output_dir.join(format!("{}.mp4", output_name)); let duration = end_time - start_time; // Quality presets (CRF values - lower is better quality) let crf = match quality.as_str() { "low" => "28", "medium" => "23", "high" => "18", _ => "23", }; // Build ffmpeg command with re-encoding let iter = FfmpegCommand::new() .arg("-y") // Overwrite output file .arg("-ss").arg(&format!("{:.3}", start_time)) .arg("-i").arg(&video_path) .arg("-t").arg(&format!("{:.3}", duration)) .arg("-c:v").arg("libx264") .arg("-crf").arg(crf) .arg("-preset").arg("fast") .arg("-c:a").arg("aac") .arg("-b:a").arg("128k") .arg(&output_path.to_string_lossy().to_string()) .spawn() .map_err(|e| format!("Failed to spawn ffmpeg: {}", e))? .iter() .map_err(|e| format!("Failed to create ffmpeg iterator: {}", e))?; // Process the ffmpeg command and collect all errors let mut errors = Vec::new(); for event in iter { match event { FfmpegEvent::Error(e) => { errors.push(format!("FFmpeg error: {}", e)); } FfmpegEvent::Log(LogLevel::Error, msg) => { errors.push(format!("FFmpeg log error: {}", msg)); } FfmpegEvent::Log(LogLevel::Warning, msg) => { eprintln!("FFmpeg warning: {}", msg); } _ => {} } } // Check if output file was created if !output_path.exists() { return Err(format!( "Export failed - output file not created. Errors: {}", errors.join("; ") )); } // Check if output file has content let metadata = fs::metadata(&output_path) .map_err(|e| format!("Failed to check output file: {}", e))?; if metadata.len() == 0 { return Err(format!( "Export failed - output file is empty. Errors: {}", errors.join("; ") )); } Ok(output_path.to_string_lossy().to_string()) } /// Check if ffmpeg is available. #[tauri::command] fn check_ffmpeg() -> bool { ffmpeg_is_installed() } /// Download ffmpeg if not already installed. #[tauri::command] fn download_ffmpeg() -> Result { ensure_ffmpeg()?; Ok(sidecar_path().unwrap_or_default().to_string_lossy().to_string()) } /// Get video file metadata using ffprobe. #[tauri::command] fn get_video_metadata(video_path: String) -> Result { // Ensure ffmpeg is available ensure_ffmpeg()?; // Use ffprobe via ffmpeg-sidecar let iter = FfmpegCommand::new() .arg("-v").arg("quiet") .arg("-print_format").arg("json") .arg("-show_format") .arg("-show_streams") .arg(&video_path) .spawn() .map_err(|e| format!("Failed to spawn ffprobe: {}", e))? .iter() .map_err(|e| format!("Failed to create ffprobe iterator: {}", e))?; let mut json_output = String::new(); for event in iter { match event { FfmpegEvent::Log(LogLevel::Info, msg) | FfmpegEvent::Log(LogLevel::Unknown, msg) => { // Capture JSON output if msg.trim().starts_with('{') || msg.trim().starts_with('[') { json_output.push_str(&msg); } } FfmpegEvent::Error(e) => { return Err(format!("FFprobe error: {}", e)); } _ => {} } } // Parse the JSON output serde_json::from_str(&json_output) .map_err(|e| format!("Failed to parse ffprobe output: {}", e)) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .setup(|app| { // Auto-start the record-daemon on app launch. // We spawn a background task so the UI isn't blocked while the // daemon boots (it can take a few seconds to initialise OBS). let handle = app.handle().clone(); tauri::async_runtime::spawn(async move { match daemon_ipc::ensure_daemon_running(30).await { Ok(true) => { eprintln!("[tauri] record-daemon is running"); } Ok(false) => { eprintln!("[tauri] record-daemon did not become ready within the timeout"); } Err(e) => { eprintln!("[tauri] failed to start record-daemon: {}", e); } } // Drop the handle – no further use drop(handle); }); Ok(()) }) .invoke_handler(tauri::generate_handler![ get_game_history, get_timeline, get_recordings_dir, find_video_file, list_video_files, export_clip, export_clip_precise, check_ffmpeg, download_ffmpeg, get_video_metadata, // Daemon IPC commands daemon_ipc::daemon_is_running, daemon_ipc::daemon_ensure_running, daemon_ipc::daemon_get_status, daemon_ipc::daemon_get_settings, daemon_ipc::daemon_update_settings, daemon_ipc::daemon_reset_settings, daemon_ipc::daemon_get_encoders, daemon_ipc::daemon_start_recording, daemon_ipc::daemon_stop_recording, daemon_ipc::daemon_shutdown, daemon_ipc::daemon_get_log_path, daemon_ipc::daemon_read_logs, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }