feat: implement ffmpeg.rs, subtitles.rs, and fonts.rs
- ffmpeg.rs: discovery, duration extraction, metadata probing, download - subtitles.rs: SRT-to-VTT conversion, sidecar discovery, storage, extraction - fonts.rs: Google Fonts and Font Awesome local caching
This commit is contained in:
806
src-tauri/src/ffmpeg.rs
Normal file
806
src-tauri/src/ffmpeg.rs
Normal file
@@ -0,0 +1,806 @@
|
||||
//! 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<Regex> =
|
||||
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<PathBuf>,
|
||||
pub ffmpeg: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Detailed video metadata extracted via ffprobe.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VideoMetadata {
|
||||
pub v_codec: Option<String>,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub fps: Option<f64>,
|
||||
pub v_bitrate: Option<u64>,
|
||||
pub pix_fmt: Option<String>,
|
||||
pub color_space: Option<String>,
|
||||
pub a_codec: Option<String>,
|
||||
pub channels: Option<u32>,
|
||||
pub sample_rate: Option<String>,
|
||||
pub a_bitrate: Option<u64>,
|
||||
pub subtitle_tracks: Vec<SubtitleTrack>,
|
||||
pub container_bitrate: Option<u64>,
|
||||
pub duration: Option<f64>,
|
||||
pub format_name: Option<String>,
|
||||
pub container_title: Option<String>,
|
||||
pub encoder: Option<String>,
|
||||
}
|
||||
|
||||
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<PathBuf> {
|
||||
// 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<f64> {
|
||||
// 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<f64> {
|
||||
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<f64> {
|
||||
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<f64> {
|
||||
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<std::process::Output> {
|
||||
// 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<VideoMetadata> {
|
||||
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::<u64>().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::<u64>().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::<u64>().ok());
|
||||
|
||||
meta.duration = json_str_from(fmt, "duration")
|
||||
.and_then(|s| s.parse::<f64>().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<String, serde_json::Value>, key: &str) -> Option<String> {
|
||||
json_str_from(obj, key)
|
||||
}
|
||||
|
||||
/// Extract a string value from a JSON map by key.
|
||||
fn json_str_from(
|
||||
obj: &serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
) -> Option<String> {
|
||||
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<String, serde_json::Value>, key: &str) -> Option<u32> {
|
||||
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<f64> {
|
||||
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<DownloadProgress>,
|
||||
) -> Result<FfmpegPaths, String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user