diff --git a/src-tauri/src/binary_manager.rs b/src-tauri/src/binary_manager.rs index 3c188ed..6bbcf0c 100644 --- a/src-tauri/src/binary_manager.rs +++ b/src-tauri/src/binary_manager.rs @@ -15,6 +15,9 @@ use crate::storage::{self}; const YT_DLP_REPO_URL: &str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download"; // Bellard's QuickJS binary releases URL const QJS_REPO_URL: &str = "https://bellard.org/quickjs/binary_releases"; +// FFmpeg builds +const FFMPEG_GITHUB_API: &str = "https://api.github.com/repos/BtbN/FFmpeg-Builds/releases/latest"; +const FFMPEG_EVERMEET_BASE: &str = "https://evermeet.cx/ffmpeg"; pub fn get_ytdlp_binary_name() -> &'static str { if cfg!(target_os = "windows") { @@ -62,10 +65,23 @@ pub fn get_qjs_path(app: &AppHandle) -> Result { Ok(get_bin_dir(app)?.join(get_qjs_binary_name())) } +pub fn get_ffmpeg_binary_name() -> &'static str { + if cfg!(target_os = "windows") { + "ffmpeg.exe" + } else { + "ffmpeg" + } +} + +pub fn get_ffmpeg_path(app: &AppHandle) -> Result { + Ok(get_bin_dir(app)?.join(get_ffmpeg_binary_name())) +} + pub fn check_binaries(app: &AppHandle) -> bool { let ytdlp = get_ytdlp_path(app).map(|p| p.exists()).unwrap_or(false); let qjs = get_qjs_path(app).map(|p| p.exists()).unwrap_or(false); - ytdlp && qjs + let ffmpeg = get_ffmpeg_path(app).map(|p| p.exists()).unwrap_or(false); + ytdlp && qjs && ffmpeg } // --- yt-dlp Logic --- @@ -272,6 +288,175 @@ pub async fn update_qjs(app: &AppHandle) -> Result { Ok("QuickJS 已更新/安装".to_string()) } +// --- FFmpeg Logic --- + +pub async fn download_ffmpeg(app: &AppHandle) -> Result { + let bin_dir = get_bin_dir(app)?; + if cfg!(target_os = "windows") { + // Query GitHub releases API to find a suitable win64 zip asset + let client = reqwest::Client::new(); + let resp = client.get(FFMPEG_GITHUB_API) + .header(reqwest::header::USER_AGENT, "stream-capture") + .send() + .await?; + if !resp.status().is_success() { + return Err(anyhow!("无法获取 FFmpeg releases 信息")); + } + let json: serde_json::Value = resp.json().await?; + let mut download_url: Option = None; + if let Some(assets) = json.get("assets").and_then(|a| a.as_array()) { + for asset in assets { + if let (Some(name), Some(url)) = (asset.get("name").and_then(|n| n.as_str()), asset.get("browser_download_url").and_then(|u| u.as_str())) { + let lname = name.to_lowercase(); + if lname.contains("win64") && lname.ends_with(".zip") { + download_url = Some(url.to_string()); + break; + } + } + } + if download_url.is_none() { + // fallback: choose first zip asset + for asset in assets { + if let (Some(url), Some(name)) = (asset.get("browser_download_url").and_then(|u| u.as_str()), asset.get("name").and_then(|n| n.as_str())) { + if name.to_lowercase().ends_with(".zip") { + download_url = Some(url.to_string()); + break; + } + } + } + } + } + + let url = download_url.ok_or(anyhow!("未找到适合 Windows 的 FFmpeg 发行包"))?; + let resp = client.get(&url).header(reqwest::header::USER_AGENT, "stream-capture").send().await?; + if !resp.status().is_success() { + return Err(anyhow!("下载 FFmpeg 失败")); + } + let bytes = resp.bytes().await?; + let mut archive = ZipArchive::new(Cursor::new(bytes))?; + + let mut found = false; + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + if file.is_dir() { continue; } + let name = file.name().to_string(); + let filename_only = name.split('/').last().unwrap_or(""); + if filename_only.eq_ignore_ascii_case("ffmpeg.exe") || filename_only.ends_with(".dll") { + let mut out_file = fs::File::create(bin_dir.join(filename_only))?; + std::io::copy(&mut file, &mut out_file)?; + found = true; + } + } + if !found { + return Err(anyhow!("在 FFmpeg 压缩包中未找到 ffmpeg 可执行文件")); + } + + let final_path = get_ffmpeg_path(app)?; + Ok(final_path) + } else if cfg!(target_os = "macos") { + // Fetch listing page and find latest ffmpeg-*.zip + let resp = reqwest::get(FFMPEG_EVERMEET_BASE).await?; + if !resp.status().is_success() { + return Err(anyhow!("无法获取 evermeet.ffmpeg 列表")); + } + let body = resp.text().await?; + let re = regex::Regex::new(r#"href="(ffmpeg-[0-9][^"]*\.zip)"#)?; + let mut candidates: Vec<&str> = Vec::new(); + for cap in re.captures_iter(&body) { + if let Some(m) = cap.get(1) { + candidates.push(m.as_str()); + } + } + let filename = candidates.last().ok_or(anyhow!("未在 evermeet 找到 ffmpeg zip 文件"))?; + let url = format!("{}/{}", FFMPEG_EVERMEET_BASE, filename); + let resp = reqwest::get(&url).await?; + if !resp.status().is_success() { + return Err(anyhow!("下载 FFmpeg macOS 版本失败")); + } + let bytes = resp.bytes().await?; + let mut archive = ZipArchive::new(Cursor::new(bytes))?; + + let mut found = false; + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + if file.is_dir() { continue; } + let name = file.name().to_string(); + let filename_only = name.split('/').last().unwrap_or(""); + if filename_only == "ffmpeg" { + let mut out_file = fs::File::create(bin_dir.join("ffmpeg"))?; + std::io::copy(&mut file, &mut out_file)?; + found = true; + } + } + if !found { + return Err(anyhow!("在 FFmpeg 压缩包中未找到 ffmpeg 可执行文件")); + } + + let final_path = get_ffmpeg_path(app)?; + #[cfg(target_family = "unix")] + { + let mut perms = fs::metadata(&final_path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&final_path, perms)?; + } + #[cfg(target_os = "macos")] + { + std::process::Command::new("xattr").arg("-d").arg("com.apple.quarantine").arg(&final_path).output().ok(); + } + Ok(final_path) + } else { + Err(anyhow!("当前操作系统不支持自动下载 FFmpeg")) + } +} + +pub async fn update_ffmpeg(app: &AppHandle) -> Result { + let path = get_ffmpeg_path(app)?; + if !path.exists() { + download_ffmpeg(app).await?; + return Ok("FFmpeg 已安装".to_string()); + } + // Re-download to update + download_ffmpeg(app).await?; + + // Update settings timestamp + let mut settings = storage::load_settings(app)?; + settings.last_updated = Some(chrono::Utc::now()); + storage::save_settings(app, &settings)?; + + Ok("FFmpeg 已更新".to_string()) +} + +pub fn get_ffmpeg_version(app: &AppHandle) -> Result { + let path = get_ffmpeg_path(app)?; + if !path.exists() { + return Ok("未安装".to_string()); + } + let mut cmd = std::process::Command::new(&path); + cmd.arg("-version"); + #[cfg(target_os = "windows")] + cmd.creation_flags(0x08000000); + + let output = cmd.output()?; + if output.status.success() { + // Prefer stdout, fallback to stderr if stdout empty + let out = if !output.stdout.is_empty() { + String::from_utf8_lossy(&output.stdout).to_string() + } else { + String::from_utf8_lossy(&output.stderr).to_string() + }; + + if let Some(first_line) = out.lines().next() { + let v = first_line.trim().to_string(); + if !v.is_empty() { + return Ok(v); + } + } + } + + // If we couldn't obtain a usable version string, treat as not installed + Ok("未安装".to_string()) +} + pub fn get_qjs_version(app: &AppHandle) -> Result { let path = get_qjs_path(app)?; if !path.exists() { diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1add16d..48aa3b2 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -29,6 +29,11 @@ pub async fn update_quickjs(app: AppHandle) -> Result { binary_manager::update_qjs(&app).await.map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn update_ffmpeg(app: AppHandle) -> Result { + binary_manager::update_ffmpeg(&app).await.map_err(|e| e.to_string()) +} + #[tauri::command] pub fn get_ytdlp_version(app: AppHandle) -> Result { binary_manager::get_ytdlp_version(&app).map_err(|e| e.to_string()) @@ -39,6 +44,11 @@ pub fn get_quickjs_version(app: AppHandle) -> Result { binary_manager::get_qjs_version(&app).map_err(|e| e.to_string()) } +#[tauri::command] +pub fn get_ffmpeg_version(app: AppHandle) -> Result { + binary_manager::get_ffmpeg_version(&app).map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn fetch_metadata(app: AppHandle, url: String, parse_mix_playlist: bool) -> Result { downloader::fetch_metadata(&app, &url, parse_mix_playlist).await.map_err(|e| e.to_string()) diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index 2eda286..5d92a01 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -166,6 +166,7 @@ pub async fn download_video( ) -> Result { let ytdlp_path = binary_manager::get_ytdlp_path(&app)?; let qjs_path = binary_manager::get_qjs_path(&app)?; // Get absolute path to quickjs + let ffmpeg_path = binary_manager::get_ffmpeg_path(&app)?; // Get absolute path to ffmpeg let mut args = Vec::new(); @@ -173,6 +174,9 @@ pub async fn download_video( args.push("--js-runtimes".to_string()); // Rust's Command automatically handles spaces in arguments, so we should NOT quote the path here. args.push(format!("quickjs:{}", qjs_path.to_string_lossy())); + // Pass ffmpeg location so yt-dlp can find our managed ffmpeg + args.push("--ffmpeg-location".to_string()); + args.push(ffmpeg_path.to_string_lossy().to_string()); args.push(url); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 602fb80..a183d9c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,8 +13,10 @@ pub fn run() { commands::init_ytdlp, commands::update_ytdlp, commands::update_quickjs, + commands::update_ffmpeg, commands::get_ytdlp_version, commands::get_quickjs_version, + commands::get_ffmpeg_version, commands::fetch_metadata, commands::start_download, commands::get_settings, diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 6f039ee..59f4f17 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -18,6 +18,7 @@ export const useSettingsStore = defineStore('settings', () => { const ytdlpVersion = ref('Checking...') const quickjsVersion = ref('Checking...') + const ffmpegVersion = ref('Checking...') const isInitializing = ref(true) async function loadSettings() { @@ -49,6 +50,7 @@ export const useSettingsStore = defineStore('settings', () => { console.error(e) ytdlpVersion.value = 'Error' quickjsVersion.value = 'Error' + ffmpegVersion.value = 'Error' } finally { isInitializing.value = false } @@ -57,6 +59,7 @@ export const useSettingsStore = defineStore('settings', () => { async function refreshVersions() { ytdlpVersion.value = await invoke('get_ytdlp_version') quickjsVersion.value = await invoke('get_quickjs_version') + ffmpegVersion.value = await invoke('get_ffmpeg_version') } function applyTheme(theme: string) { @@ -76,5 +79,5 @@ export const useSettingsStore = defineStore('settings', () => { } }) - return { settings, loadSettings, save, initYtdlp, refreshVersions, ytdlpVersion, quickjsVersion, isInitializing } + return { settings, loadSettings, save, initYtdlp, refreshVersions, ytdlpVersion, quickjsVersion, ffmpegVersion, isInitializing } }) \ No newline at end of file diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 6d58894..7922756 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -9,6 +9,7 @@ import { format } from 'date-fns' // Import format const settingsStore = useSettingsStore() const updatingYtdlp = ref(false) const updatingQuickjs = ref(false) +const updatingFfmpeg = ref(false) const updateStatus = ref('') async function browsePath() { @@ -52,6 +53,20 @@ async function updateQuickjs() { } } +async function updateFfmpeg() { + updatingFfmpeg.value = true + updateStatus.value = '正在更新 FFmpeg...' + try { + const res = await invoke('update_ffmpeg') + updateStatus.value = res + await settingsStore.refreshVersions() + } catch (e: any) { + updateStatus.value = 'FFmpeg 错误:' + e.toString() + } finally { + updatingFfmpeg.value = false + } +} + function setTheme(theme: 'light' | 'dark' | 'system') { settingsStore.settings.theme = theme settingsStore.save() @@ -160,6 +175,27 @@ function setTheme(theme: 'light' | 'dark' | 'system') { 更新 + + +
+
+
+ +
+
+
FFmpeg
+
{{ settingsStore.ffmpegVersion === '未安装' ? '未安装' : '已安装' }}
+
+
+ +