Files
stream-capture/src-tauri/src/downloader.rs
2025-12-08 10:16:42 -04:00

290 lines
9.3 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> {
app.emit("download-log", LogEvent {
id: "Analysis".to_string(),
message: format!("Starting metadata fetch for URL: {}", url),
level: "info".to_string(),
}).ok();
let ytdlp_path = binary_manager::get_ytdlp_path(app)?;
let qjs_path = binary_manager::get_qjs_path(app)?; // Get absolute path to quickjs
let mut cmd = Command::new(ytdlp_path);
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000);
// Pass the runtime and its absolute path to --js-runtimes
cmd.arg("--js-runtimes").arg(format!("quickjs:{}", qjs_path.to_string_lossy()));
cmd.arg("--dump-single-json")
.arg("--flat-playlist")
.arg("--no-warnings");
// Optimize metadata fetching: skip heavy manifests and player JS execution.
// Skipping JS prevents slow QuickJS spin-up and signature decryption, drastically speeding up single video parsing.
cmd.arg("--extractor-args").arg("youtube:skip=dash,hls,translated_subs;player_skip=js");
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);
app.emit("download-log", LogEvent {
id: "Analysis".to_string(),
message: format!("Metadata fetch failed: {}", stderr),
level: "error".to_string(),
}).ok();
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));
}
let result = MetadataResult::Playlist(PlaylistMetadata {
id: json["id"].as_str().unwrap_or("").to_string(),
title: json["title"].as_str().unwrap_or("Unknown Playlist").to_string(),
entries,
});
app.emit("download-log", LogEvent {
id: "Analysis".to_string(),
message: "Metadata fetch success (Playlist)".to_string(),
level: "info".to_string(),
}).ok();
return Ok(result);
}
}
// Single video
let result = MetadataResult::Video(parse_video_metadata(&json));
app.emit("download-log", LogEvent {
id: "Analysis".to_string(),
message: "Metadata fetch success (Video)".to_string(),
level: "info".to_string(),
}).ok();
Ok(result)
}
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)?;
let qjs_path = binary_manager::get_qjs_path(&app)?; // Get absolute path to quickjs
let mut args = Vec::new();
// Pass the runtime and its absolute path to --js-runtimes
args.push("--js-runtimes".to_string());
args.push(format!("quickjs:{}", qjs_path.to_string_lossy()));
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());
} 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());
// Log the full command
let full_cmd_str = format!("{} {}", ytdlp_path.to_string_lossy(), args.join(" "));
app.emit("download-log", LogEvent {
id: id.clone(),
message: format!("Executing command: {}", full_cmd_str),
level: "info".to_string(),
}).ok();
let mut cmd = Command::new(ytdlp_path);
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000);
let mut child = cmd
.args(&args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.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"))
}
}