Files
leaguerecorder/tauri-app/src-tauri/src/lib.rs
T
vhaudiquet 45aac067f8
record-daemon / Build, check and test (push) Successful in 2m23s
feat: add option to launch app at Windows startup
- Add tauri-plugin-autostart dependency to Cargo.toml
- Add autostart permissions to capabilities/default.json
- Initialize autostart plugin in lib.rs with LaunchAgent config
- Add "Launch at Startup" toggle in Settings.vue Daemon tab with state management and async enable/disable functions
2026-06-15 20:10:49 +02:00

452 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<PathBuf> {
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<Value> {
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::<Value>(&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<Value> {
load_timelines()
}
/// Get a specific timeline by recording ID as raw JSON.
#[tauri::command]
fn get_timeline(recording_id: String) -> Option<Value> {
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::<Value>(&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<String> {
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<String> {
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<String, String> {
// 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<String, String> {
// 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<String, String> {
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<Value, String> {
// 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())
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
Some(vec!["--hidden"]),
))
.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");
}