//! FFmpeg / FFprobe discovery, video metadata extraction, and ffmpeg downloading. use once_cell::sync::Lazy; use regex::Regex; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /// CREATE_NO_WINDOW flag for Windows subprocess creation. #[cfg(target_os = "windows")] const CREATE_NO_WINDOW: u32 = 0x08000000; /// Timeout for ffprobe / ffmpeg subprocess calls. const SUBPROCESS_TIMEOUT: Duration = Duration::from_secs(20); /// Timeout for ffprobe metadata calls (slightly longer for large files). const METADATA_TIMEOUT: Duration = Duration::from_secs(25); /// FFmpeg download URL (Windows 64-bit GPL build). const FFMPEG_DOWNLOAD_URL: &str = "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip"; // --------------------------------------------------------------------------- // Regex patterns // --------------------------------------------------------------------------- /// Matches "Duration: HH:MM:SS.ss" in ffmpeg stderr output. static DURATION_RE: Lazy = Lazy::new(|| Regex::new(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)").unwrap()); // --------------------------------------------------------------------------- // Structs // --------------------------------------------------------------------------- /// Paths to discovered ffprobe and ffmpeg executables. pub struct FfmpegPaths { pub ffprobe: Option, pub ffmpeg: Option, } /// Detailed video metadata extracted via ffprobe. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VideoMetadata { pub v_codec: Option, pub width: Option, pub height: Option, pub fps: Option, pub v_bitrate: Option, pub pix_fmt: Option, pub color_space: Option, pub a_codec: Option, pub channels: Option, pub sample_rate: Option, pub a_bitrate: Option, pub subtitle_tracks: Vec, pub container_bitrate: Option, pub duration: Option, pub format_name: Option, pub container_title: Option, pub encoder: Option, } impl Default for VideoMetadata { fn default() -> Self { Self { v_codec: None, width: None, height: None, fps: None, v_bitrate: None, pix_fmt: None, color_space: None, a_codec: None, channels: None, sample_rate: None, a_bitrate: None, subtitle_tracks: Vec::new(), container_bitrate: None, duration: None, format_name: None, container_title: None, encoder: None, } } } /// Information about a single subtitle track. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SubtitleTrack { pub index: u32, pub codec: String, pub language: String, pub title: String, } /// Progress information emitted during ffmpeg download. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DownloadProgress { pub percent: f64, pub downloaded_bytes: u64, pub total_bytes: u64, } // --------------------------------------------------------------------------- // 1. Platform-specific command setup // --------------------------------------------------------------------------- /// Apply platform-specific flags to a `Command` (hide console window on Windows). fn apply_no_window(_cmd: &mut Command) { #[cfg(target_os = "windows")] { _cmd.creation_flags(CREATE_NO_WINDOW); } } // --------------------------------------------------------------------------- // 2. discover // --------------------------------------------------------------------------- /// Discover ffmpeg and ffprobe executables. /// /// Search order: /// 1. System PATH via `which` /// 2. Alongside the application executable (`exe_dir`) /// 3. Inside `state_dir/ffmpeg/` pub fn discover(exe_dir: &Path, state_dir: &Path) -> FfmpegPaths { let ffprobe = discover_one("ffprobe", exe_dir, state_dir); let ffmpeg = discover_one("ffmpeg", exe_dir, state_dir); FfmpegPaths { ffprobe, ffmpeg } } /// Discover a single executable by name. fn discover_one(name: &str, exe_dir: &Path, state_dir: &Path) -> Option { // 1. System PATH if let Ok(p) = which::which(name) { return Some(p); } // Platform-specific executable name let exe_name = if cfg!(target_os = "windows") { format!("{}.exe", name) } else { name.to_string() }; // 2. Beside the application executable let candidate = exe_dir.join(&exe_name); if candidate.is_file() { return Some(candidate); } // 3. Inside state_dir/ffmpeg/ let candidate = state_dir.join("ffmpeg").join(&exe_name); if candidate.is_file() { return Some(candidate); } None } // --------------------------------------------------------------------------- // 3. duration_seconds // --------------------------------------------------------------------------- /// Get video duration in seconds using ffprobe (primary) or ffmpeg stderr (fallback). /// /// Returns `None` if neither method succeeds or duration is not positive. pub fn duration_seconds(path: &Path, paths: &FfmpegPaths) -> Option { // Try ffprobe first if let Some(ref ffprobe) = paths.ffprobe { if let Some(d) = duration_via_ffprobe(path, ffprobe) { return Some(d); } } // Fallback: parse ffmpeg stderr if let Some(ref ffmpeg) = paths.ffmpeg { if let Some(d) = duration_via_ffmpeg(path, ffmpeg) { return Some(d); } } None } /// Extract duration using ffprobe's format=duration output. fn duration_via_ffprobe(path: &Path, ffprobe: &Path) -> Option { let mut cmd = Command::new(ffprobe); cmd.args([ "-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", ]) .arg(path) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); apply_no_window(&mut cmd); let child = cmd.spawn().ok()?; let output = wait_with_timeout(child, SUBPROCESS_TIMEOUT)?; let text = String::from_utf8_lossy(&output.stdout); let trimmed = text.trim(); if trimmed.is_empty() { return None; } let d: f64 = trimmed.parse().ok()?; if d > 0.0 { Some(d) } else { None } } /// Extract duration by parsing "Duration: HH:MM:SS.ss" from ffmpeg stderr. fn duration_via_ffmpeg(path: &Path, ffmpeg: &Path) -> Option { let mut cmd = Command::new(ffmpeg); cmd.args(["-hide_banner", "-i"]) .arg(path) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); apply_no_window(&mut cmd); let child = cmd.spawn().ok()?; let output = wait_with_timeout(child, SUBPROCESS_TIMEOUT)?; let stderr = String::from_utf8_lossy(&output.stderr); parse_ffmpeg_duration(&stderr) } /// Parse "Duration: HH:MM:SS.ss" from ffmpeg stderr output. fn parse_ffmpeg_duration(stderr: &str) -> Option { let caps = DURATION_RE.captures(stderr)?; let hh: f64 = caps.get(1)?.as_str().parse().ok()?; let mm: f64 = caps.get(2)?.as_str().parse().ok()?; let ss: f64 = caps.get(3)?.as_str().parse().ok()?; let total = hh * 3600.0 + mm * 60.0 + ss; if total > 0.0 { Some(total) } else { None } } /// Wait for a child process with a timeout, killing it if exceeded. fn wait_with_timeout( child: std::process::Child, timeout: Duration, ) -> Option { // Convert Child into a form we can wait on with a timeout. // std::process::Child::wait_with_output blocks, so we use a thread. let (tx, rx) = std::sync::mpsc::channel(); let handle = std::thread::spawn(move || { let result = child.wait_with_output(); let _ = tx.send(result); }); match rx.recv_timeout(timeout) { Ok(Ok(output)) => { let _ = handle.join(); Some(output) } _ => { // Timeout or error -- the thread owns the child and will clean up let _ = handle.join(); None } } } // --------------------------------------------------------------------------- // 4. ffprobe_video_metadata // --------------------------------------------------------------------------- /// Extract detailed video metadata using ffprobe JSON output. /// /// Runs ffprobe with `-print_format json -show_streams -show_format` and parses /// the resulting JSON to populate a `VideoMetadata` struct. pub fn ffprobe_video_metadata(path: &Path, ffprobe: &Path) -> Option { let mut cmd = Command::new(ffprobe); cmd.args([ "-v", "error", "-print_format", "json", "-show_streams", "-show_format", ]) .arg(path) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); apply_no_window(&mut cmd); let child = cmd.spawn().ok()?; let output = wait_with_timeout(child, METADATA_TIMEOUT)?; let text = String::from_utf8_lossy(&output.stdout); let data: serde_json::Value = serde_json::from_str(&text).ok()?; let streams = data.get("streams").and_then(|v| v.as_array()); let fmt = data.get("format").and_then(|v| v.as_object()); let mut meta = VideoMetadata::default(); let mut found_video = false; let mut found_audio = false; // Iterate streams: first video, first audio, all subtitles if let Some(streams) = streams { for (idx, s) in streams.iter().enumerate() { let obj = match s.as_object() { Some(o) => o, None => continue, }; let codec_type = obj .get("codec_type") .and_then(|v| v.as_str()) .unwrap_or(""); match codec_type { "video" if !found_video => { found_video = true; meta.v_codec = json_str(obj, "codec_name"); meta.width = json_u32(obj, "width"); meta.height = json_u32(obj, "height"); meta.pix_fmt = json_str(obj, "pix_fmt"); meta.color_space = json_str(obj, "color_space"); // Parse frame rate ("num/den") let frame_rate = json_str(obj, "r_frame_rate") .or_else(|| json_str(obj, "avg_frame_rate")); if let Some(ref fr) = frame_rate { meta.fps = parse_frame_rate(fr); } // Video bitrate meta.v_bitrate = json_str(obj, "bit_rate") .and_then(|s| s.parse::().ok()); } "audio" if !found_audio => { found_audio = true; meta.a_codec = json_str(obj, "codec_name"); meta.channels = json_u32(obj, "channels"); meta.sample_rate = json_str(obj, "sample_rate"); meta.a_bitrate = json_str(obj, "bit_rate") .and_then(|s| s.parse::().ok()); } "subtitle" => { let tags = obj .get("tags") .and_then(|v| v.as_object()); let language = tags .and_then(|t| { json_str_from(t, "language") .or_else(|| json_str_from(t, "LANGUAGE")) }) .unwrap_or_default(); let title = tags .and_then(|t| { json_str_from(t, "title") .or_else(|| json_str_from(t, "TITLE")) }) .unwrap_or_default(); let stream_index = obj .get("index") .and_then(|v| v.as_u64()) .unwrap_or(idx as u64) as u32; let codec = json_str(obj, "codec_name") .unwrap_or_else(|| "unknown".to_string()); meta.subtitle_tracks.push(SubtitleTrack { index: stream_index, codec, language, title, }); } _ => {} } } } // Format-level metadata if let Some(fmt) = fmt { meta.container_bitrate = json_str_from(fmt, "bit_rate") .and_then(|s| s.parse::().ok()); meta.duration = json_str_from(fmt, "duration") .and_then(|s| s.parse::().ok()); meta.format_name = json_str_from(fmt, "format_name"); // Container tags if let Some(ftags) = fmt.get("tags").and_then(|v| v.as_object()) { let ct = json_str_from(ftags, "title"); if ct.as_deref().map_or(false, |s| !s.is_empty()) { meta.container_title = ct; } let enc = json_str_from(ftags, "encoder"); if enc.as_deref().map_or(false, |s| !s.is_empty()) { meta.encoder = enc; } } } // Return None if we extracted nothing useful let has_data = meta.v_codec.is_some() || meta.a_codec.is_some() || !meta.subtitle_tracks.is_empty() || meta.duration.is_some() || meta.format_name.is_some(); if has_data { Some(meta) } else { None } } // --------------------------------------------------------------------------- // JSON helper functions // --------------------------------------------------------------------------- /// Extract a string value from a JSON object by key. fn json_str(obj: &serde_json::Map, key: &str) -> Option { json_str_from(obj, key) } /// Extract a string value from a JSON map by key. fn json_str_from( obj: &serde_json::Map, key: &str, ) -> Option { obj.get(key).and_then(|v| match v { serde_json::Value::String(s) => Some(s.clone()), serde_json::Value::Number(n) => Some(n.to_string()), _ => None, }) } /// Extract a u32 value from a JSON object by key. fn json_u32(obj: &serde_json::Map, key: &str) -> Option { obj.get(key).and_then(|v| v.as_u64()).map(|n| n as u32) } /// Parse a frame rate string like "30000/1001" into an f64. fn parse_frame_rate(fr: &str) -> Option { if let Some((num_str, den_str)) = fr.split_once('/') { let num: f64 = num_str.trim().parse().ok()?; let den: f64 = den_str.trim().parse().ok()?; if den == 0.0 { return None; } let fps = num / den; if fps > 0.0 { Some(fps) } else { None } } else { // Plain number let fps: f64 = fr.trim().parse().ok()?; if fps > 0.0 { Some(fps) } else { None } } } // --------------------------------------------------------------------------- // 5. download_ffmpeg (async) // --------------------------------------------------------------------------- /// Download ffmpeg from GitHub, extract `ffmpeg.exe` and `ffprobe.exe`, and /// place them in `state_dir/ffmpeg/`. /// /// Reports progress via the provided `tokio::sync::mpsc::Sender`. pub async fn download_ffmpeg( state_dir: &Path, progress_tx: tokio::sync::mpsc::Sender, ) -> Result { let dest_dir = state_dir.join("ffmpeg"); std::fs::create_dir_all(&dest_dir) .map_err(|e| format!("Failed to create ffmpeg directory: {}", e))?; let zip_path = dest_dir.join("ffmpeg-download.zip"); // Start the download let client = reqwest::Client::new(); let response = client .get(FFMPEG_DOWNLOAD_URL) .send() .await .map_err(|e| format!("Failed to start download: {}", e))?; if !response.status().is_success() { return Err(format!("Download failed with status: {}", response.status())); } let total_bytes = response.content_length().unwrap_or(0); let mut downloaded_bytes: u64 = 0; // Stream the response body to disk chunk by chunk using reqwest's chunk() let mut file = std::fs::File::create(&zip_path) .map_err(|e| format!("Failed to create zip file: {}", e))?; let mut response = response; while let Some(chunk) = response .chunk() .await .map_err(|e| format!("Download stream error: {}", e))? { std::io::Write::write_all(&mut file, &chunk) .map_err(|e| format!("Failed to write chunk: {}", e))?; downloaded_bytes += chunk.len() as u64; let percent = if total_bytes > 0 { (downloaded_bytes as f64 / total_bytes as f64) * 100.0 } else { 0.0 }; // Send progress update (ignore send errors if receiver dropped) let _ = progress_tx .send(DownloadProgress { percent, downloaded_bytes, total_bytes, }) .await; } drop(file); // Extract ffmpeg.exe and ffprobe.exe from the zip extract_ffmpeg_from_zip(&zip_path, &dest_dir)?; // Clean up the zip file std::fs::remove_file(&zip_path).ok(); // Verify the extracted files exist let ffmpeg_exe = if cfg!(target_os = "windows") { "ffmpeg.exe" } else { "ffmpeg" }; let ffprobe_exe = if cfg!(target_os = "windows") { "ffprobe.exe" } else { "ffprobe" }; let ffmpeg_path = dest_dir.join(ffmpeg_exe); let ffprobe_path = dest_dir.join(ffprobe_exe); Ok(FfmpegPaths { ffprobe: if ffprobe_path.is_file() { Some(ffprobe_path) } else { None }, ffmpeg: if ffmpeg_path.is_file() { Some(ffmpeg_path) } else { None }, }) } /// Extract only `ffmpeg.exe` and `ffprobe.exe` from a downloaded zip archive. /// /// The BtbN builds have files nested inside a directory like /// `ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe`, so we search for any entry /// whose filename ends with the target name. fn extract_ffmpeg_from_zip(zip_path: &Path, dest_dir: &Path) -> Result<(), String> { let file = std::fs::File::open(zip_path) .map_err(|e| format!("Failed to open zip: {}", e))?; let mut archive = zip::ZipArchive::new(file) .map_err(|e| format!("Failed to read zip archive: {}", e))?; let targets: &[&str] = if cfg!(target_os = "windows") { &["ffmpeg.exe", "ffprobe.exe"] } else { &["ffmpeg", "ffprobe"] }; for i in 0..archive.len() { let mut entry = archive .by_index(i) .map_err(|e| format!("Failed to read zip entry {}: {}", i, e))?; let entry_name = entry.name().to_string(); // Check if this entry matches one of our target filenames for target in targets { if entry_name.ends_with(&format!("/{}", target)) || entry_name == *target { let out_path = dest_dir.join(target); let mut out_file = std::fs::File::create(&out_path) .map_err(|e| format!("Failed to create {}: {}", target, e))?; std::io::copy(&mut entry, &mut out_file) .map_err(|e| format!("Failed to extract {}: {}", target, e))?; break; } } } Ok(()) } // =========================================================================== // Tests // =========================================================================== #[cfg(test)] mod tests { use super::*; // -- parse_ffmpeg_duration ----------------------------------------------- #[test] fn test_parse_ffmpeg_duration_standard() { let stderr = r#" Input #0, matroska,webm, from 'video.mkv': Duration: 01:23:45.67, start: 0.000000, bitrate: 5000 kb/s Stream #0:0: Video: h264 "#; let d = parse_ffmpeg_duration(stderr).unwrap(); let expected = 1.0 * 3600.0 + 23.0 * 60.0 + 45.67; assert!((d - expected).abs() < 0.001, "got {}, expected {}", d, expected); } #[test] fn test_parse_ffmpeg_duration_short_video() { let stderr = " Duration: 00:00:30.50, start: 0.000000, bitrate: 1200 kb/s\n"; let d = parse_ffmpeg_duration(stderr).unwrap(); assert!((d - 30.5).abs() < 0.001); } #[test] fn test_parse_ffmpeg_duration_whole_seconds() { let stderr = " Duration: 00:05:00.00, start: 0.0\n"; let d = parse_ffmpeg_duration(stderr).unwrap(); assert!((d - 300.0).abs() < 0.001); } #[test] fn test_parse_ffmpeg_duration_zero() { // Zero duration should return None (not positive) let stderr = " Duration: 00:00:00.00, start: 0.0\n"; assert!(parse_ffmpeg_duration(stderr).is_none()); } #[test] fn test_parse_ffmpeg_duration_no_match() { let stderr = "some random output without duration info\n"; assert!(parse_ffmpeg_duration(stderr).is_none()); } #[test] fn test_parse_ffmpeg_duration_empty() { assert!(parse_ffmpeg_duration("").is_none()); } // -- discover_not_found -------------------------------------------------- #[test] fn test_discover_not_found() { // Use non-existent directories -- should return None for both paths let exe_dir = Path::new("/nonexistent/path/that/does/not/exist/exe"); let state_dir = Path::new("/nonexistent/path/that/does/not/exist/state"); let paths = discover(exe_dir, state_dir); // On a system without ffmpeg in PATH these will be None; // on a system with ffmpeg installed they may be Some. // We simply verify the function does not panic. let _ = paths.ffprobe; let _ = paths.ffmpeg; } // -- video_metadata_default ---------------------------------------------- #[test] fn test_video_metadata_default() { let meta = VideoMetadata::default(); assert!(meta.v_codec.is_none()); assert!(meta.width.is_none()); assert!(meta.height.is_none()); assert!(meta.fps.is_none()); assert!(meta.v_bitrate.is_none()); assert!(meta.pix_fmt.is_none()); assert!(meta.color_space.is_none()); assert!(meta.a_codec.is_none()); assert!(meta.channels.is_none()); assert!(meta.sample_rate.is_none()); assert!(meta.a_bitrate.is_none()); assert!(meta.subtitle_tracks.is_empty()); assert!(meta.container_bitrate.is_none()); assert!(meta.duration.is_none()); assert!(meta.format_name.is_none()); assert!(meta.container_title.is_none()); assert!(meta.encoder.is_none()); } // -- download_progress_serialization ------------------------------------- #[test] fn test_download_progress_serialization() { let progress = DownloadProgress { percent: 50.5, downloaded_bytes: 1024, total_bytes: 2048, }; let json = serde_json::to_string(&progress).unwrap(); let parsed: DownloadProgress = serde_json::from_str(&json).unwrap(); assert!((parsed.percent - 50.5).abs() < f64::EPSILON); assert_eq!(parsed.downloaded_bytes, 1024); assert_eq!(parsed.total_bytes, 2048); } #[test] fn test_download_progress_json_fields() { let progress = DownloadProgress { percent: 100.0, downloaded_bytes: 5000, total_bytes: 5000, }; let value: serde_json::Value = serde_json::to_value(&progress).unwrap(); assert_eq!(value["percent"], 100.0); assert_eq!(value["downloaded_bytes"], 5000); assert_eq!(value["total_bytes"], 5000); } // -- parse_frame_rate ---------------------------------------------------- #[test] fn test_parse_frame_rate_fraction() { let fps = parse_frame_rate("30000/1001").unwrap(); assert!((fps - 29.97).abs() < 0.01); } #[test] fn test_parse_frame_rate_integer() { let fps = parse_frame_rate("24/1").unwrap(); assert!((fps - 24.0).abs() < 0.001); } #[test] fn test_parse_frame_rate_zero_denominator() { assert!(parse_frame_rate("24/0").is_none()); } #[test] fn test_parse_frame_rate_plain_number() { let fps = parse_frame_rate("60").unwrap(); assert!((fps - 60.0).abs() < 0.001); } // -- subtitle_track_serialization ---------------------------------------- #[test] fn test_subtitle_track_serialization() { let track = SubtitleTrack { index: 2, codec: "srt".to_string(), language: "eng".to_string(), title: "English".to_string(), }; let json = serde_json::to_string(&track).unwrap(); let parsed: SubtitleTrack = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.index, 2); assert_eq!(parsed.codec, "srt"); assert_eq!(parsed.language, "eng"); assert_eq!(parsed.title, "English"); } // -- integration tests (require actual ffprobe/ffmpeg) ------------------- #[test] #[ignore] fn test_duration_seconds_with_real_ffprobe() { // This test requires ffprobe and ffmpeg to be installed and a sample // video file at the given path. let exe_dir = Path::new("."); let state_dir = Path::new("."); let paths = discover(exe_dir, state_dir); if paths.ffprobe.is_none() && paths.ffmpeg.is_none() { eprintln!("Skipping: no ffprobe or ffmpeg found"); return; } // Would need a real video file here // let d = duration_seconds(Path::new("sample.mp4"), &paths); // assert!(d.is_some()); } #[test] #[ignore] fn test_ffprobe_video_metadata_with_real_ffprobe() { let exe_dir = Path::new("."); let state_dir = Path::new("."); let paths = discover(exe_dir, state_dir); if let Some(ref ffprobe) = paths.ffprobe { // Would need a real video file here // let meta = ffprobe_video_metadata(Path::new("sample.mp4"), ffprobe); // assert!(meta.is_some()); let _ = ffprobe; } else { eprintln!("Skipping: no ffprobe found"); } } }