Compare commits

...

3 Commits

Author SHA1 Message Date
Julian Freeman
eada45bd9c add format convert support 2025-12-08 17:48:22 -04:00
Julian Freeman
77407c7e28 modify height 2025-12-08 17:24:59 -04:00
Julian Freeman
ac00c54ca3 add ffmpeg 2025-12-08 17:16:46 -04:00
9 changed files with 306 additions and 12 deletions

View File

@@ -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<PathBuf> {
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<PathBuf> {
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<String> {
Ok("QuickJS 已更新/安装".to_string())
}
// --- FFmpeg Logic ---
pub async fn download_ffmpeg(app: &AppHandle) -> Result<PathBuf> {
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<String> = 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<String> {
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<String> {
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<String> {
let path = get_qjs_path(app)?;
if !path.exists() {

View File

@@ -29,6 +29,11 @@ pub async fn update_quickjs(app: AppHandle) -> Result<String, String> {
binary_manager::update_qjs(&app).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn update_ffmpeg(app: AppHandle) -> Result<String, String> {
binary_manager::update_ffmpeg(&app).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_ytdlp_version(app: AppHandle) -> Result<String, String> {
binary_manager::get_ytdlp_version(&app).map_err(|e| e.to_string())
@@ -39,6 +44,11 @@ pub fn get_quickjs_version(app: AppHandle) -> Result<String, String> {
binary_manager::get_qjs_version(&app).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_ffmpeg_version(app: AppHandle) -> Result<String, String> {
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::MetadataResult, String> {
downloader::fetch_metadata(&app, &url, parse_mix_playlist).await.map_err(|e| e.to_string())
@@ -67,7 +77,7 @@ pub async fn start_download(app: AppHandle, url: String, options: DownloadOption
output_path: output_dir,
timestamp: chrono::Utc::now(),
status: status.to_string(),
format: options.quality,
format: options.output_format,
};
let _ = storage::add_history_item(&app, item);

View File

@@ -36,6 +36,7 @@ pub struct DownloadOptions {
pub is_audio_only: bool,
pub quality: String, // e.g., "1080", "720", "best"
pub output_path: String, // Directory
pub output_format: String, // "original", "mp4", "webm", "mkv", "m4a", "aac", "opus", "vorbis", "wav", etc.
}
#[derive(Serialize, Clone, Debug)]
@@ -166,6 +167,7 @@ pub async fn download_video(
) -> Result<String> {
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 +175,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);
@@ -184,8 +189,11 @@ pub async fn download_video(
// Formats
if options.is_audio_only {
args.push("-x".to_string());
// Only set audio format if not "original"
if options.output_format != "original" {
args.push("--audio-format".to_string());
args.push("mp3".to_string());
args.push(options.output_format.clone());
}
} else {
let format_arg = if options.quality == "best" {
"bestvideo+bestaudio/best".to_string()
@@ -194,6 +202,12 @@ pub async fn download_video(
};
args.push("-f".to_string());
args.push(format_arg);
// Only set merge output format if not "original"
if options.output_format != "original" {
args.push("--merge-output-format".to_string());
args.push(options.output_format.clone());
}
}
// Progress output

View File

@@ -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,

View File

@@ -12,9 +12,9 @@
"app": {
"windows": [
{
"title": "流萤 - 视频下载",
"title": "流萤 - 视频下载 v1.0.0",
"width": 1300,
"height": 850
"height": 900
}
],
"security": {

View File

@@ -15,7 +15,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
const options = ref({
is_audio_only: false,
quality: 'best',
output_path: ''
output_path: '',
output_format: 'original'
})
function toggleEntry(id: string) {

View File

@@ -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 }
})

View File

@@ -18,6 +18,26 @@ const qualityOptions = [
{ label: '480p', value: '480' },
]
const videoFormatOptions = [
{ label: '原格式', value: 'original' },
{ label: 'MP4', value: 'mp4' },
{ label: 'WebM', value: 'webm' },
{ label: 'Matroska (MKV)', value: 'mkv' },
{ label: 'FLV', value: 'flv' },
{ label: 'AVI', value: 'avi' },
]
const audioFormatOptions = [
{ label: '原格式', value: 'original' },
{ label: 'MP3', value: 'mp3' },
{ label: 'M4A', value: 'm4a' },
{ label: 'AAC', value: 'aac' },
{ label: 'Opus', value: 'opus' },
{ label: 'Vorbis', value: 'vorbis' },
{ label: 'WAV', value: 'wav' },
{ label: 'FLAC', value: 'flac' },
]
// Sync default download path if not set
watch(() => settingsStore.settings.download_path, (newPath) => {
if (newPath && !analysisStore.options.output_path) {
@@ -36,6 +56,11 @@ watch(() => analysisStore.url, (newUrl) => {
}
})
// Reset format to original when toggling audio-only mode
watch(() => analysisStore.options.is_audio_only, () => {
analysisStore.options.output_format = 'original'
})
async function analyze() {
if (!analysisStore.url) return
analysisStore.loading = true
@@ -217,9 +242,9 @@ async function startDownload() {
<!-- Left: Selection Controls -->
<div class="flex items-center gap-2 w-full md:w-auto">
<button @click="analysisStore.setAllEntries(true)" class="text-xs font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">全选</button>
<button @click="analysisStore.setAllEntries(false)" class="text-xs font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">取消全选</button>
<button @click="analysisStore.invertSelection()" class="text-xs font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">反选</button>
<button @click="analysisStore.setAllEntries(true)" class="text-base font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">全选</button>
<button @click="analysisStore.setAllEntries(false)" class="text-base font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">取消全选</button>
<button @click="analysisStore.invertSelection()" class="text-base font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">反选</button>
</div>
<!-- Right: Settings -->
@@ -249,6 +274,16 @@ async function startDownload() {
/>
</div>
</div>
<!-- Format Dropdown -->
<div class="flex items-center gap-3">
<div class="w-48">
<AppSelect
v-model="analysisStore.options.output_format"
:options="analysisStore.options.is_audio_only ? audioFormatOptions : videoFormatOptions"
/>
</div>
</div>
</div>
</div>
</div>
@@ -298,7 +333,7 @@ async function startDownload() {
<p v-if="analysisStore.metadata.entries" class="text-blue-500 mt-1 font-medium">{{ analysisStore.metadata.entries.length }} 个视频 (播放列表)</p>
<!-- Options -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
<!-- Audio Only Toggle -->
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-xl">
<span class="font-medium text-base">仅音频</span>
@@ -322,6 +357,14 @@ async function startDownload() {
:disabled="analysisStore.options.is_audio_only"
/>
</div>
<!-- Format Dropdown -->
<div class="relative">
<AppSelect
v-model="analysisStore.options.output_format"
:options="analysisStore.options.is_audio_only ? audioFormatOptions : videoFormatOptions"
/>
</div>
</div>
</div>
</div>

View File

@@ -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<string>('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') {
更新
</button>
</div>
<!-- FFmpeg -->
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-zinc-800 rounded-xl">
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-white dark:bg-zinc-700 rounded-lg flex items-center justify-center shadow-sm">
<Terminal class="w-5 h-5 text-purple-600" />
</div>
<div>
<div class="font-medium text-zinc-900 dark:text-white">FFmpeg</div>
<div class="text-xs text-gray-500 mt-0.5 font-mono">{{ settingsStore.ffmpegVersion === '未安装' ? '未安装' : '已安装' }}</div>
</div>
</div>
<button
@click="updateFfmpeg"
:disabled="updatingFfmpeg"
class="text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 px-4 py-2 rounded-lg transition-colors text-sm font-medium flex items-center gap-2 disabled:opacity-50"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': updatingFfmpeg }" />
更新
</button>
</div>
</div>
<div v-if="updateStatus" class="mt-4 p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg text-xs font-mono text-gray-600 dark:text-gray-400 whitespace-pre-wrap border border-gray-200 dark:border-zinc-700">