253 lines
8.2 KiB
Rust
253 lines
8.2 KiB
Rust
// filepath: src-tauri/src/downloader.rs
|
|
use tauri::{AppHandle, Emitter};
|
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
|
use tokio::process::Command;
|
|
use std::process::Stdio;
|
|
use serde::{Deserialize, Serialize};
|
|
use anyhow::{Result, anyhow};
|
|
use regex::Regex;
|
|
use crate::binary_manager;
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct VideoMetadata {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub thumbnail: String,
|
|
pub duration: Option<f64>,
|
|
pub uploader: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct PlaylistMetadata {
|
|
pub id: String,
|
|
pub title: String,
|
|
pub entries: Vec<VideoMetadata>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
#[serde(untagged)]
|
|
pub enum MetadataResult {
|
|
Video(VideoMetadata),
|
|
Playlist(PlaylistMetadata),
|
|
}
|
|
|
|
#[derive(Deserialize, Debug, Clone)]
|
|
pub struct DownloadOptions {
|
|
pub is_audio_only: bool,
|
|
pub quality: String, // e.g., "1080", "720", "best"
|
|
pub output_path: String, // Directory
|
|
}
|
|
|
|
#[derive(Serialize, Clone, Debug)]
|
|
pub struct ProgressEvent {
|
|
pub id: String,
|
|
pub progress: f64,
|
|
pub speed: String,
|
|
pub status: String, // "downloading", "processing", "finished", "error"
|
|
}
|
|
|
|
#[derive(Serialize, Clone, Debug)]
|
|
pub struct LogEvent {
|
|
pub id: String,
|
|
pub message: String,
|
|
pub level: String, // "info", "error"
|
|
}
|
|
|
|
pub async fn fetch_metadata(app: &AppHandle, url: &str, parse_mix_playlist: bool) -> Result<MetadataResult> {
|
|
let ytdlp_path = binary_manager::get_ytdlp_path(app)?; // Updated path call
|
|
|
|
// Inject PATH for QuickJS
|
|
let bin_dir = binary_manager::get_bin_dir(app)?;
|
|
let path_env = std::env::var("PATH").unwrap_or_default();
|
|
let new_path_env = format!("{}{}{}", bin_dir.to_string_lossy(), if cfg!(windows) { ";" } else { ":" }, path_env);
|
|
|
|
let mut cmd = Command::new(ytdlp_path);
|
|
|
|
// Environment injection
|
|
cmd.env("PATH", new_path_env);
|
|
cmd.arg("--js-runtime").arg("quickjs"); // Force QuickJS
|
|
|
|
cmd.arg("--dump-single-json")
|
|
.arg("--flat-playlist")
|
|
.arg("--no-warnings");
|
|
|
|
if parse_mix_playlist {
|
|
cmd.arg("--playlist-end").arg("20");
|
|
}
|
|
|
|
cmd.arg(url);
|
|
cmd.stderr(Stdio::piped());
|
|
|
|
let output = cmd.output().await?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(anyhow!("yt-dlp error: {}", stderr));
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let json: serde_json::Value = serde_json::from_str(&stdout)?;
|
|
|
|
// Check if playlist
|
|
if let Some(_type) = json.get("_type") {
|
|
if _type == "playlist" {
|
|
let entries_json = json["entries"].as_array().ok_or(anyhow!("No entries in playlist"))?;
|
|
let mut entries = Vec::new();
|
|
|
|
for entry in entries_json {
|
|
entries.push(parse_video_metadata(entry));
|
|
}
|
|
|
|
return Ok(MetadataResult::Playlist(PlaylistMetadata {
|
|
id: json["id"].as_str().unwrap_or("").to_string(),
|
|
title: json["title"].as_str().unwrap_or("Unknown Playlist").to_string(),
|
|
entries,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Single video
|
|
Ok(MetadataResult::Video(parse_video_metadata(&json)))
|
|
}
|
|
|
|
fn parse_video_metadata(json: &serde_json::Value) -> VideoMetadata {
|
|
let id = json["id"].as_str().unwrap_or("").to_string();
|
|
|
|
// Thumbnail fallback logic
|
|
let thumbnail = match json.get("thumbnail").and_then(|t| t.as_str()) {
|
|
Some(t) if !t.is_empty() => t.to_string(),
|
|
_ => format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id),
|
|
};
|
|
|
|
VideoMetadata {
|
|
id,
|
|
title: json["title"].as_str().unwrap_or("Unknown Title").to_string(),
|
|
thumbnail,
|
|
duration: json["duration"].as_f64(),
|
|
uploader: json["uploader"].as_str().map(|s| s.to_string()),
|
|
}
|
|
}
|
|
|
|
pub async fn download_video(
|
|
app: AppHandle,
|
|
id: String, // Unique ID for this download task (provided by frontend)
|
|
url: String,
|
|
options: DownloadOptions,
|
|
) -> Result<String> {
|
|
let ytdlp_path = binary_manager::get_ytdlp_path(&app)?; // Updated path call
|
|
|
|
// Inject PATH for QuickJS
|
|
let bin_dir = binary_manager::get_bin_dir(&app)?;
|
|
let path_env = std::env::var("PATH").unwrap_or_default();
|
|
let new_path_env = format!("{}{}{}", bin_dir.to_string_lossy(), if cfg!(windows) { ";" } else { ":" }, path_env);
|
|
|
|
let mut args = Vec::new();
|
|
|
|
// JS Runtime args must be passed via .arg(), env is set on command builder
|
|
args.push("--js-runtime".to_string());
|
|
args.push("quickjs".to_string());
|
|
|
|
args.push(url);
|
|
|
|
// Output template
|
|
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);
|
|
|
|
// Formats
|
|
if options.is_audio_only {
|
|
args.push("-x".to_string());
|
|
args.push("--audio-format".to_string());
|
|
args.push("mp3".to_string()); // Defaulting to mp3 for simplicity
|
|
} else {
|
|
let format_arg = if options.quality == "best" {
|
|
"bestvideo+bestaudio/best".to_string()
|
|
} else {
|
|
format!("bestvideo[height<={}]+bestaudio/best[height<={}]", options.quality, options.quality)
|
|
};
|
|
args.push("-f".to_string());
|
|
args.push(format_arg);
|
|
}
|
|
|
|
// Progress output
|
|
args.push("--newline".to_string()); // Easier parsing
|
|
|
|
let mut child = Command::new(ytdlp_path)
|
|
.env("PATH", new_path_env) // Inject PATH
|
|
.args(&args)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped()) // Capture stderr for logs
|
|
.spawn()?;
|
|
|
|
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 re = Regex::new(r"\[download\]\s+(\d+\.?\d*)%").unwrap();
|
|
|
|
// 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
|
|
}
|
|
|
|
// Parse progress
|
|
if let Some(caps) = re.captures(&out_line) {
|
|
if let Some(pct_match) = caps.get(1) {
|
|
if let Ok(pct) = pct_match.as_str().parse::<f64>() {
|
|
app.emit("download-progress", ProgressEvent {
|
|
id: id.clone(),
|
|
progress: pct,
|
|
speed: "TODO".to_string(),
|
|
status: "downloading".to_string(),
|
|
}).ok();
|
|
}
|
|
}
|
|
} 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();
|
|
}
|
|
}
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let status = child.wait().await?;
|
|
|
|
if status.success() {
|
|
app.emit("download-progress", ProgressEvent {
|
|
id: id.clone(),
|
|
progress: 100.0,
|
|
speed: "-".to_string(),
|
|
status: "finished".to_string(),
|
|
}).ok();
|
|
Ok("Download complete".to_string())
|
|
} else {
|
|
app.emit("download-progress", ProgressEvent {
|
|
id: id.clone(),
|
|
progress: 0.0,
|
|
speed: "-".to_string(),
|
|
status: "error".to_string(),
|
|
}).ok();
|
|
Err(anyhow!("Download process failed"))
|
|
}
|
|
} |