369 lines
12 KiB
Rust
369 lines
12 KiB
Rust
use std::fs;
|
||
use std::io::Write;
|
||
use std::path::{Path, PathBuf};
|
||
use tokio::process::Command;
|
||
use tokio_stream::StreamExt;
|
||
|
||
const FFMPEG_VERSION: &str = "7.1";
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct FFmpegInstaller;
|
||
|
||
impl FFmpegInstaller {
|
||
pub fn new() -> Self {
|
||
Self
|
||
}
|
||
|
||
/// 获取 FFmpeg 存储路径
|
||
pub fn get_ffmpeg_path() -> PathBuf {
|
||
let app_dir = Self::get_app_data_dir();
|
||
#[cfg(target_os = "windows")]
|
||
let ffmpeg_name = "ffmpeg.exe";
|
||
#[cfg(not(target_os = "windows"))]
|
||
let ffmpeg_name = "ffmpeg";
|
||
app_dir.join(ffmpeg_name)
|
||
}
|
||
|
||
/// 获取应用数据目录
|
||
fn get_app_data_dir() -> PathBuf {
|
||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
||
#[cfg(target_os = "macos")]
|
||
let app_dir = home.join("Library/Application Support/FormatConverter");
|
||
#[cfg(target_os = "windows")]
|
||
let app_dir = home.join("AppData/Local/FormatConverter");
|
||
#[cfg(target_os = "linux")]
|
||
let app_dir = home.join(".local/share/format-converter");
|
||
|
||
fs::create_dir_all(&app_dir).ok();
|
||
app_dir
|
||
}
|
||
|
||
/// 检查 FFmpeg 是否已安装
|
||
pub fn is_installed() -> bool {
|
||
// 1. 检查系统 PATH 中的 FFmpeg
|
||
if let Ok(output) = std::process::Command::new("which")
|
||
.arg("ffmpeg")
|
||
.output()
|
||
{
|
||
if output.status.success() && !output.stdout.is_empty() {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// 2. 检查应用目录
|
||
let app_ffmpeg = Self::get_ffmpeg_path();
|
||
if app_ffmpeg.exists() {
|
||
return true;
|
||
}
|
||
|
||
// 3. 检查系统常见路径
|
||
let common_paths = Self::get_system_ffmpeg_paths();
|
||
for path in common_paths {
|
||
if path.exists() {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
false
|
||
}
|
||
|
||
/// 获取可能的系统 FFmpeg 路径
|
||
fn get_system_ffmpeg_paths() -> Vec<PathBuf> {
|
||
let mut paths = Vec::new();
|
||
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
// Homebrew 常见安装路径
|
||
paths.push(PathBuf::from("/opt/homebrew/bin/ffmpeg")); // Apple Silicon
|
||
paths.push(PathBuf::from("/usr/local/bin/ffmpeg")); // Intel
|
||
// 其他可能路径
|
||
paths.push(PathBuf::from("/usr/bin/ffmpeg"));
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
paths.push(PathBuf::from("/usr/bin/ffmpeg"));
|
||
paths.push(PathBuf::from("/usr/local/bin/ffmpeg"));
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
paths.push(PathBuf::from("C:\\ffmpeg\\bin\\ffmpeg.exe"));
|
||
if let Ok(program_files) = std::env::var("ProgramFiles") {
|
||
paths.push(PathBuf::from(&format!("{}\\ffmpeg\\bin\\ffmpeg.exe", program_files)));
|
||
}
|
||
}
|
||
|
||
paths
|
||
}
|
||
|
||
/// 获取 FFmpeg 版本
|
||
pub async fn get_version() -> Option<String> {
|
||
// 1. 尝试系统 PATH 中的 ffmpeg
|
||
if let Ok(output) = tokio::time::timeout(
|
||
std::time::Duration::from_secs(5),
|
||
Command::new("ffmpeg").args(["-version"]).output()
|
||
).await {
|
||
if let Ok(output) = output {
|
||
if output.status.success() {
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
if let Some(first_line) = stdout.lines().next() {
|
||
return Some(first_line.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. 尝试应用目录中的 ffmpeg
|
||
let ffmpeg_path = Self::get_ffmpeg_path();
|
||
if ffmpeg_path.exists() {
|
||
if let Some(cmd_str) = ffmpeg_path.to_str() {
|
||
if let Ok(Ok(output)) = tokio::time::timeout(
|
||
std::time::Duration::from_secs(5),
|
||
Command::new(cmd_str).args(["-version"]).output()
|
||
).await {
|
||
if output.status.success() {
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
if let Some(first_line) = stdout.lines().next() {
|
||
return Some(first_line.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 尝试系统常见路径
|
||
let system_paths = Self::get_system_ffmpeg_paths();
|
||
for path in system_paths {
|
||
if path.exists() {
|
||
if let Some(cmd_str) = path.to_str() {
|
||
if let Ok(Ok(output)) = tokio::time::timeout(
|
||
std::time::Duration::from_secs(5),
|
||
Command::new(cmd_str).args(["-version"]).output()
|
||
).await {
|
||
if output.status.success() {
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
if let Some(first_line) = stdout.lines().next() {
|
||
return Some(first_line.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// 下载并安装 FFmpeg
|
||
pub async fn install<F>(progress_callback: F) -> Result<(), String>
|
||
where
|
||
F: Fn(f64, String) + Send + 'static,
|
||
{
|
||
let platform = Self::detect_platform()?;
|
||
let download_url = Self::get_download_url(&platform);
|
||
|
||
progress_callback(0.0, "准备下载 FFmpeg...".to_string());
|
||
|
||
// 创建临时目录
|
||
let temp_dir = std::env::temp_dir().join("format-converter-ffmpeg");
|
||
fs::create_dir_all(&temp_dir).map_err(|e: std::io::Error| format!("创建临时目录失败: {}", e))?;
|
||
|
||
// 下载文件
|
||
progress_callback(5.0, "正在下载 FFmpeg...".to_string());
|
||
let archive_path = temp_dir.join(format!("ffmpeg.{}", platform.extension()));
|
||
|
||
Self::download_file(&download_url, &archive_path, |downloaded, total| {
|
||
let percent = if total > 0 {
|
||
(downloaded as f64 / total as f64) * 40.0 + 5.0
|
||
} else {
|
||
5.0
|
||
};
|
||
progress_callback(percent, format!("正在下载 FFmpeg... {:.1}%", percent));
|
||
})
|
||
.await
|
||
.map_err(|e| format!("下载失败: {}", e))?;
|
||
|
||
// 解压
|
||
progress_callback(50.0, "正在解压...".to_string());
|
||
Self::extract_archive(&archive_path, &temp_dir, &platform)
|
||
.await
|
||
.map_err(|e| format!("解压失败: {}", e))?;
|
||
|
||
// 移动到目标位置
|
||
progress_callback(90.0, "正在安装...".to_string());
|
||
let ffmpeg_path = Self::get_ffmpeg_path();
|
||
let extracted_ffmpeg = Self::find_ffmpeg_binary(&temp_dir, &platform)
|
||
.ok_or("在解压文件中未找到 FFmpeg 可执行文件")?;
|
||
|
||
fs::copy(&extracted_ffmpeg, &ffmpeg_path)
|
||
.map_err(|e| format!("复制文件失败: {}", e))?;
|
||
|
||
// 设置可执行权限(Unix)
|
||
#[cfg(not(target_os = "windows"))]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let mut perms = fs::metadata(&ffmpeg_path).unwrap().permissions();
|
||
perms.set_mode(0o755);
|
||
fs::set_permissions(&ffmpeg_path, perms).unwrap();
|
||
}
|
||
|
||
// 清理临时文件
|
||
progress_callback(95.0, "正在清理...".to_string());
|
||
fs::remove_dir_all(&temp_dir).ok();
|
||
|
||
progress_callback(100.0, "FFmpeg 安装完成".to_string());
|
||
Ok(())
|
||
}
|
||
|
||
/// 检测平台
|
||
fn detect_platform() -> Result<Platform, String> {
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
// 检测架构
|
||
let arch = std::env::consts::ARCH;
|
||
match arch {
|
||
"aarch64" => Ok(Platform::MacOSArm64),
|
||
"x86_64" => Ok(Platform::MacOSX64),
|
||
_ => Err(format!("不支持的架构: {}", arch)),
|
||
}
|
||
}
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
let arch = std::env::consts::ARCH;
|
||
match arch {
|
||
"x86_64" => Ok(Platform::WindowsX64),
|
||
"aarch64" => Ok(Platform::WindowsArm64),
|
||
_ => Err(format!("不支持的架构: {}", arch)),
|
||
}
|
||
}
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
let arch = std::env::consts::ARCH;
|
||
match arch {
|
||
"x86_64" => Ok(Platform::LinuxX64),
|
||
"aarch64" => Ok(Platform::LinuxArm64),
|
||
_ => Err(format!("不支持的架构: {}", arch)),
|
||
}
|
||
}
|
||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||
{
|
||
Err("不支持的操作系统".to_string())
|
||
}
|
||
}
|
||
|
||
/// 获取下载 URL
|
||
fn get_download_url(platform: &Platform) -> String {
|
||
match platform {
|
||
Platform::MacOSArm64 => format!(
|
||
"https://evermeet.cx/pub/ffmpeg/ffmpeg-{}-macos-arm64.zip",
|
||
FFMPEG_VERSION
|
||
),
|
||
Platform::MacOSX64 => format!(
|
||
"https://evermeet.cx/pub/ffmpeg/ffmpeg-{}-macos-x86_64.zip",
|
||
FFMPEG_VERSION
|
||
),
|
||
Platform::WindowsX64 => format!(
|
||
"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"
|
||
),
|
||
Platform::WindowsArm64 => format!(
|
||
"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"
|
||
),
|
||
Platform::LinuxX64 => format!(
|
||
"https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"
|
||
),
|
||
Platform::LinuxArm64 => format!(
|
||
"https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz"
|
||
),
|
||
}
|
||
}
|
||
|
||
/// 下载文件
|
||
async fn download_file<F>(url: &str, path: &Path, progress: F) -> Result<(), String>
|
||
where
|
||
F: Fn(u64, u64),
|
||
{
|
||
let client = reqwest::Client::new();
|
||
let response = client
|
||
.get(url)
|
||
.timeout(std::time::Duration::from_secs(300))
|
||
.send()
|
||
.await
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
let total_size = response.content_length().unwrap_or(0);
|
||
|
||
let mut file = fs::File::create(path).map_err(|e| e.to_string())?;
|
||
let mut downloaded: u64 = 0;
|
||
let mut stream = response.bytes_stream();
|
||
|
||
while let Some(chunk) = stream.next().await {
|
||
let chunk = chunk.map_err(|e: reqwest::Error| e.to_string())?;
|
||
file.write_all(&chunk).map_err(|e: std::io::Error| e.to_string())?;
|
||
downloaded += chunk.len() as u64;
|
||
progress(downloaded, total_size);
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// 解压归档文件
|
||
async fn extract_archive(
|
||
archive_path: &Path,
|
||
output_dir: &Path,
|
||
platform: &Platform,
|
||
) -> Result<(), String> {
|
||
match platform.extension().as_str() {
|
||
"zip" => {
|
||
let file = fs::File::open(archive_path).map_err(|e| e.to_string())?;
|
||
let mut archive = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||
archive.extract(output_dir).map_err(|e| e.to_string())?;
|
||
}
|
||
"tar.xz" => {
|
||
let file = fs::File::open(archive_path).map_err(|e| e.to_string())?;
|
||
let tar = xz2::read::XzDecoder::new(file);
|
||
let mut archive = tar::Archive::new(tar);
|
||
archive.unpack(output_dir).map_err(|e| e.to_string())?;
|
||
}
|
||
_ => return Err("不支持的压缩格式".to_string()),
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 查找 FFmpeg 可执行文件
|
||
fn find_ffmpeg_binary(dir: &Path, _platform: &Platform) -> Option<PathBuf> {
|
||
#[cfg(target_os = "windows")]
|
||
let binary_name = "ffmpeg.exe";
|
||
#[cfg(not(target_os = "windows"))]
|
||
let binary_name = "ffmpeg";
|
||
|
||
for entry in walkdir::WalkDir::new(dir) {
|
||
if let Ok(entry) = entry {
|
||
if entry.file_name() == binary_name {
|
||
return Some(entry.path().to_path_buf());
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
enum Platform {
|
||
MacOSArm64,
|
||
MacOSX64,
|
||
WindowsX64,
|
||
WindowsArm64,
|
||
LinuxX64,
|
||
LinuxArm64,
|
||
}
|
||
|
||
impl Platform {
|
||
fn extension(&self) -> String {
|
||
match self {
|
||
Platform::MacOSArm64 | Platform::MacOSX64 | Platform::WindowsX64 | Platform::WindowsArm64 => "zip".to_string(),
|
||
Platform::LinuxX64 | Platform::LinuxArm64 => "tar.xz".to_string(),
|
||
}
|
||
}
|
||
}
|