// 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::ytdlp; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct VideoMetadata { pub id: String, pub title: String, pub thumbnail: String, pub duration: Option, pub uploader: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PlaylistMetadata { pub id: String, pub title: String, pub entries: Vec, } #[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 { let ytdlp_path = ytdlp::get_ytdlp_path(app)?; let mut cmd = Command::new(ytdlp_path); 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 { let ytdlp_path = ytdlp::get_ytdlp_path(&app)?; let mut args = Vec::new(); 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) .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::() { 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")) } }