op 1
This commit is contained in:
1
src-tauri/.gitignore
vendored
1
src-tauri/.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/target*/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::os::windows::process::CommandExt;
|
||||
use zip::ZipArchive;
|
||||
use std::io::Cursor;
|
||||
|
||||
use crate::process_utils::first_non_empty_line;
|
||||
use crate::storage::{self};
|
||||
|
||||
const YT_DLP_REPO_URL: &str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download";
|
||||
@@ -285,6 +286,11 @@ pub async fn download_qjs(app: &AppHandle) -> Result<PathBuf> {
|
||||
pub async fn update_qjs(app: &AppHandle) -> Result<String> {
|
||||
// QuickJS doesn't have self-update, so we just re-download
|
||||
download_qjs(app).await?;
|
||||
|
||||
let mut settings = storage::load_settings(app)?;
|
||||
settings.last_updated = Some(chrono::Utc::now());
|
||||
storage::save_settings(app, &settings)?;
|
||||
|
||||
Ok("QuickJS 已更新/安装".to_string())
|
||||
}
|
||||
|
||||
@@ -441,18 +447,8 @@ pub fn get_ffmpeg_version(app: &AppHandle) -> Result<String> {
|
||||
|
||||
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 let Some(line) = first_non_empty_line(&output) {
|
||||
return Ok(line);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +461,31 @@ pub fn get_qjs_version(app: &AppHandle) -> Result<String> {
|
||||
if !path.exists() {
|
||||
return Ok("未安装".to_string());
|
||||
}
|
||||
|
||||
let mut version_cmd = std::process::Command::new(&path);
|
||||
version_cmd.arg("--version");
|
||||
#[cfg(target_os = "windows")]
|
||||
version_cmd.creation_flags(0x08000000);
|
||||
|
||||
if let Ok(output) = version_cmd.output() {
|
||||
if output.status.success() {
|
||||
if let Some(line) = first_non_empty_line(&output) {
|
||||
return Ok(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut help_cmd = std::process::Command::new(&path);
|
||||
help_cmd.arg("-h");
|
||||
#[cfg(target_os = "windows")]
|
||||
help_cmd.creation_flags(0x08000000);
|
||||
|
||||
if let Ok(output) = help_cmd.output() {
|
||||
if let Some(line) = first_non_empty_line(&output) {
|
||||
return Ok(line);
|
||||
}
|
||||
}
|
||||
|
||||
Ok("已安装".to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@ use crate::downloader::DownloadOptions;
|
||||
use crate::storage::{Settings, HistoryItem};
|
||||
use uuid::Uuid;
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
static DOWNLOAD_SEMAPHORE: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(3));
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn init_ytdlp(app: AppHandle) -> Result<bool, String> {
|
||||
@@ -60,14 +64,29 @@ pub async fn fetch_image(url: String) -> Result<String, String> {
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
return Err(format!("image fetch failed with status {}", res.status()));
|
||||
}
|
||||
|
||||
let mime = res
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.split(';').next().unwrap_or("image/jpeg").to_string())
|
||||
.unwrap_or_else(|| {
|
||||
if url.to_lowercase().ends_with(".png") {
|
||||
"image/png".to_string()
|
||||
} else if url.to_lowercase().ends_with(".webp") {
|
||||
"image/webp".to_string()
|
||||
} else {
|
||||
"image/jpeg".to_string()
|
||||
}
|
||||
});
|
||||
let bytes = res.bytes().await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Convert to base64
|
||||
let b64 = general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
// Simple heuristic for mime type
|
||||
let mime = if url.to_lowercase().ends_with(".png") { "image/png" } else { "image/jpeg" };
|
||||
|
||||
|
||||
Ok(format!("data:{};base64,{}", mime, b64))
|
||||
}
|
||||
|
||||
@@ -84,9 +103,11 @@ pub async fn start_download(app: AppHandle, url: String, options: DownloadOption
|
||||
|
||||
// Spawn the download task
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let _permit = DOWNLOAD_SEMAPHORE.acquire().await.ok();
|
||||
let res = downloader::download_video(app.clone(), id_clone.clone(), url.clone(), options.clone()).await;
|
||||
|
||||
let status = if res.is_ok() { "success" } else { "failed" };
|
||||
let file_path = res.ok().flatten();
|
||||
|
||||
// Add to history
|
||||
let output_dir = options.output_path.clone(); // Store the directory user selected
|
||||
@@ -97,6 +118,7 @@ pub async fn start_download(app: AppHandle, url: String, options: DownloadOption
|
||||
thumbnail: metadata.thumbnail,
|
||||
url: url,
|
||||
output_path: output_dir,
|
||||
file_path,
|
||||
timestamp: chrono::Utc::now(),
|
||||
status: status.to_string(),
|
||||
format: options.output_format,
|
||||
@@ -136,16 +158,16 @@ pub fn delete_history_item(app: AppHandle, id: String) -> Result<(), String> {
|
||||
#[tauri::command]
|
||||
pub async fn close_splash(app: AppHandle) {
|
||||
if let Some(splash) = app.get_webview_window("splashscreen") {
|
||||
splash.close().unwrap();
|
||||
let _ = splash.close();
|
||||
}
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
main.show().unwrap();
|
||||
let _ = main.show();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_in_explorer(app: AppHandle, path: String) -> Result<(), String> {
|
||||
let path_to_open = if Path::new(&path).exists() {
|
||||
let resolved_path = if Path::new(&path).exists() {
|
||||
path
|
||||
} else {
|
||||
app.path().download_dir()
|
||||
@@ -155,17 +177,29 @@ pub fn open_in_explorer(app: AppHandle, path: String) -> Result<(), String> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("explorer")
|
||||
.arg(path_to_open)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resolved = Path::new(&resolved_path);
|
||||
let mut command = std::process::Command::new("explorer");
|
||||
|
||||
if resolved.is_file() {
|
||||
command.arg("/select,").arg(resolved);
|
||||
} else {
|
||||
command.arg(resolved);
|
||||
}
|
||||
|
||||
command.spawn().map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(path_to_open)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resolved = Path::new(&resolved_path);
|
||||
let mut command = std::process::Command::new("open");
|
||||
|
||||
if resolved.is_file() {
|
||||
command.arg("-R").arg(resolved);
|
||||
} else {
|
||||
command.arg(resolved);
|
||||
}
|
||||
|
||||
command.spawn().map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ pub struct LogEvent {
|
||||
pub level: String, // "info", "error"
|
||||
}
|
||||
|
||||
const FINAL_PATH_MARKER: &str = "__STREAM_CAPTURE_FINAL_PATH__";
|
||||
|
||||
|
||||
pub async fn fetch_metadata(app: &AppHandle, url: &str, parse_mix_playlist: bool) -> Result<MetadataResult> {
|
||||
@@ -218,7 +219,7 @@ pub async fn download_video(
|
||||
id: String, // Unique ID for this download task (provided by frontend)
|
||||
url: String,
|
||||
options: DownloadOptions,
|
||||
) -> Result<String> {
|
||||
) -> Result<Option<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
|
||||
@@ -246,6 +247,8 @@ pub async fn download_video(
|
||||
let output_template = format!("{}/%(title)s.%(ext)s", options.output_path.trim_end_matches(std::path::MAIN_SEPARATOR));
|
||||
args.push("-o".to_string());
|
||||
args.push(output_template);
|
||||
args.push("--print".to_string());
|
||||
args.push(format!("after_move:{FINAL_PATH_MARKER}%(filepath)s"));
|
||||
|
||||
// Formats
|
||||
if options.is_audio_only {
|
||||
@@ -295,56 +298,84 @@ pub async fn download_video(
|
||||
let stdout = child.stdout.take().ok_or(anyhow!("Failed to open stdout"))?;
|
||||
let stderr = child.stderr.take().ok_or(anyhow!("Failed to open stderr"))?;
|
||||
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
let progress_regex = Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%.*?(?:\s+at\s+([^\s]+))?").unwrap();
|
||||
|
||||
let re = Regex::new(r"\[download\]\s+(\d+\.?\d*)%").unwrap();
|
||||
let stdout_task = {
|
||||
let app = app.clone();
|
||||
let id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
let mut final_path: Option<String> = None;
|
||||
|
||||
// Loop to read both streams
|
||||
loop {
|
||||
let mut out_line = String::new();
|
||||
let mut err_line = String::new();
|
||||
|
||||
tokio::select! {
|
||||
res = stdout_reader.read_line(&mut out_line) => {
|
||||
if res.unwrap_or(0) == 0 {
|
||||
break; // EOF
|
||||
while let Some(line) = reader.next_line().await? {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse progress
|
||||
if let Some(caps) = re.captures(&out_line) {
|
||||
|
||||
if let Some(path) = trimmed.strip_prefix(FINAL_PATH_MARKER) {
|
||||
final_path = Some(path.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(caps) = progress_regex.captures(trimmed) {
|
||||
if let Some(pct_match) = caps.get(1) {
|
||||
if let Ok(pct) = pct_match.as_str().parse::<f64>() {
|
||||
let speed = caps
|
||||
.get(2)
|
||||
.map(|value| value.as_str().to_string())
|
||||
.unwrap_or_else(|| "待定".to_string());
|
||||
|
||||
app.emit("download-progress", ProgressEvent {
|
||||
id: id.clone(),
|
||||
progress: pct,
|
||||
speed: "待定".to_string(),
|
||||
speed,
|
||||
status: "downloading".to_string(),
|
||||
}).ok();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else { // Only emit download-log if it's NOT a progress line
|
||||
app.emit("download-log", LogEvent {
|
||||
id: id.clone(),
|
||||
message: out_line.trim().to_string(),
|
||||
level: "info".to_string(),
|
||||
}).ok();
|
||||
}
|
||||
|
||||
app.emit("download-log", LogEvent {
|
||||
id: id.clone(),
|
||||
message: trimmed.to_string(),
|
||||
level: "info".to_string(),
|
||||
}).ok();
|
||||
}
|
||||
res = stderr_reader.read_line(&mut err_line) => {
|
||||
if res.unwrap_or(0) > 0 {
|
||||
// Log error
|
||||
app.emit("download-log", LogEvent {
|
||||
id: id.clone(),
|
||||
message: err_line.trim().to_string(),
|
||||
level: "error".to_string(),
|
||||
}).ok();
|
||||
}
|
||||
|
||||
Ok::<Option<String>, anyhow::Error>(final_path)
|
||||
})
|
||||
};
|
||||
|
||||
let stderr_task = {
|
||||
let app = app.clone();
|
||||
let id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr).lines();
|
||||
let mut last_error: Option<String> = None;
|
||||
|
||||
while let Some(line) = reader.next_line().await? {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
last_error = Some(trimmed.to_string());
|
||||
app.emit("download-log", LogEvent {
|
||||
id: id.clone(),
|
||||
message: trimmed.to_string(),
|
||||
level: "error".to_string(),
|
||||
}).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<Option<String>, anyhow::Error>(last_error)
|
||||
})
|
||||
};
|
||||
|
||||
let status = child.wait().await?;
|
||||
let final_path = stdout_task.await.map_err(|e| anyhow!(e.to_string()))??;
|
||||
let last_error = stderr_task.await.map_err(|e| anyhow!(e.to_string()))??;
|
||||
|
||||
if status.success() {
|
||||
app.emit("download-progress", ProgressEvent {
|
||||
@@ -353,7 +384,7 @@ pub async fn download_video(
|
||||
speed: "-".to_string(),
|
||||
status: "finished".to_string(),
|
||||
}).ok();
|
||||
Ok("下载完成".to_string())
|
||||
Ok(final_path)
|
||||
} else {
|
||||
app.emit("download-progress", ProgressEvent {
|
||||
id: id.clone(),
|
||||
@@ -361,6 +392,11 @@ pub async fn download_video(
|
||||
speed: "-".to_string(),
|
||||
status: "error".to_string(),
|
||||
}).ok();
|
||||
Err(anyhow!("下载进程失败"))
|
||||
Err(anyhow!(
|
||||
"下载进程失败{}",
|
||||
last_error
|
||||
.map(|message| format!(": {message}"))
|
||||
.unwrap_or_default()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ mod binary_manager;
|
||||
mod downloader;
|
||||
mod storage;
|
||||
mod commands;
|
||||
mod process_utils;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
|
||||
10
src-tauri/src/process_utils.rs
Normal file
10
src-tauri/src/process_utils.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use std::process::Output;
|
||||
|
||||
pub fn first_non_empty_line(output: &Output) -> Option<String> {
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.chain(String::from_utf8_lossy(&output.stderr).lines())
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
@@ -34,11 +34,35 @@ pub struct HistoryItem {
|
||||
pub thumbnail: String,
|
||||
pub url: String,
|
||||
pub output_path: String,
|
||||
#[serde(default)]
|
||||
pub file_path: Option<String>,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub status: String, // "success", "failed"
|
||||
pub format: String,
|
||||
}
|
||||
|
||||
fn write_json_atomically(path: &PathBuf, content: &str) -> Result<()> {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("data.json");
|
||||
let tmp_path = path.with_file_name(format!("{file_name}.tmp"));
|
||||
|
||||
fs::write(&tmp_path, content)?;
|
||||
|
||||
if path.exists() {
|
||||
match fs::rename(&tmp_path, path) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(_) => {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs::rename(&tmp_path, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_app_data_dir(app: &AppHandle) -> Result<PathBuf> {
|
||||
// In Tauri v2, we use app.path().app_data_dir()
|
||||
let path = app.path().app_data_dir()?;
|
||||
@@ -76,7 +100,7 @@ pub fn load_settings(app: &AppHandle) -> Result<Settings> {
|
||||
pub fn save_settings(app: &AppHandle, settings: &Settings) -> Result<()> {
|
||||
let path = get_settings_path(app)?;
|
||||
let content = serde_json::to_string_pretty(settings)?;
|
||||
fs::write(path, content)?;
|
||||
write_json_atomically(&path, &content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -94,7 +118,7 @@ pub fn load_history(app: &AppHandle) -> Result<Vec<HistoryItem>> {
|
||||
pub fn save_history(app: &AppHandle, history: &[HistoryItem]) -> Result<()> {
|
||||
let path = get_history_path(app)?;
|
||||
let content = serde_json::to_string_pretty(history)?;
|
||||
fs::write(path, content)?;
|
||||
write_json_atomically(&path, &content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self' asset: http://asset.localhost https://asset.localhost; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost ws://localhost:1420 http://localhost:1420 https:; font-src 'self' asset: http://asset.localhost https://asset.localhost data:;"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
Reference in New Issue
Block a user