This commit is contained in:
Julian Freeman
2025-12-02 09:11:59 -04:00
parent f4e264708a
commit a18065de93
23 changed files with 2909 additions and 183 deletions

108
src-tauri/src/commands.rs Normal file
View File

@@ -0,0 +1,108 @@
// filepath: src-tauri/src/commands.rs
use tauri::AppHandle;
use crate::{ytdlp, downloader, storage};
use crate::downloader::DownloadOptions;
use crate::storage::{Settings, HistoryItem};
use uuid::Uuid;
#[tauri::command]
pub async fn init_ytdlp(app: AppHandle) -> Result<bool, String> {
if ytdlp::check_ytdlp(&app) {
return Ok(true);
}
// If not found, try to download
match ytdlp::download_ytdlp(&app).await {
Ok(_) => Ok(true),
Err(e) => Err(format!("Failed to download yt-dlp: {}", e)),
}
}
#[tauri::command]
pub async fn update_ytdlp(app: AppHandle) -> Result<String, String> {
ytdlp::update_ytdlp(&app).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_ytdlp_version(app: AppHandle) -> Result<String, String> {
ytdlp::get_version(&app).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn fetch_metadata(app: AppHandle, url: String) -> Result<downloader::MetadataResult, String> {
downloader::fetch_metadata(&app, &url).await.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn start_download(app: AppHandle, url: String, options: DownloadOptions, metadata: downloader::VideoMetadata) -> Result<String, String> {
// Generate a task ID
let id = Uuid::new_v4().to_string();
let id_clone = id.clone();
// Spawn the download task
tauri::async_runtime::spawn(async move {
let res = downloader::download_video(app.clone(), id_clone.clone(), url.clone(), options.clone()).await;
let status = if res.is_ok() { "success" } else { "failed" };
// Add to history
let item = HistoryItem {
id: id_clone,
title: metadata.title,
thumbnail: metadata.thumbnail,
url: url,
output_path: format!("{}/", options.output_path), // Rough path, real path is dynamic
timestamp: chrono::Utc::now(),
status: status.to_string(),
format: options.quality,
};
let _ = storage::add_history_item(&app, item);
});
Ok(id)
}
#[tauri::command]
pub fn get_settings(app: AppHandle) -> Result<Settings, String> {
storage::load_settings(&app).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn save_settings(app: AppHandle, settings: Settings) -> Result<(), String> {
storage::save_settings(&app, &settings).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn get_history(app: AppHandle) -> Result<Vec<HistoryItem>, String> {
storage::load_history(&app).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn clear_history(app: AppHandle) -> Result<(), String> {
storage::clear_history(&app).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn delete_history_item(app: AppHandle, id: String) -> Result<(), String> {
storage::delete_history_item(&app, &id).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn open_in_explorer(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.arg(path)
.spawn()
.map_err(|e| e.to_string())?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(path)
.spawn()
.map_err(|e| e.to_string())?;
}
Ok(())
}

185
src-tauri/src/downloader.rs Normal file
View File

@@ -0,0 +1,185 @@
// 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<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"
}
pub async fn fetch_metadata(app: &AppHandle, url: &str) -> Result<MetadataResult> {
let ytdlp_path = ytdlp::get_ytdlp_path(app)?;
// Use std::process for simple output capture if it's short, but tokio is safer for async.
let output = Command::new(ytdlp_path)
.arg("--dump-single-json")
.arg("--flat-playlist")
.arg(url)
// Stop errors from cluttering
.stderr(Stdio::piped())
.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 {
VideoMetadata {
id: json["id"].as_str().unwrap_or("").to_string(),
title: json["title"].as_str().unwrap_or("Unknown Title").to_string(),
thumbnail: json["thumbnail"].as_str().unwrap_or("").to_string(), // Note: thumbnails might be an array sometimes, usually string in flat-playlist
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 = 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())
.spawn()?;
let stdout = child.stdout.take().ok_or(anyhow!("Failed to open stdout"))?;
let mut reader = BufReader::new(stdout);
let mut line = String::new();
// Regex for progress: [download] 42.5% of 10.00MiB at 2.00MiB/s ETA 00:05
let re = Regex::new(r"\[download\]\s+(\d+\.?\d*)%").unwrap();
while reader.read_line(&mut line).await? > 0 {
if let Some(caps) = re.captures(&line) {
if let Some(pct_match) = caps.get(1) {
if let Ok(pct) = pct_match.as_str().parse::<f64>() {
// Emit event
app.emit("download-progress", ProgressEvent {
id: id.clone(),
progress: pct,
speed: "TODO".to_string(), // Speed parsing is a bit more complex, skipping for MVP or adding regex for it
status: "downloading".to_string(),
}).ok();
}
}
}
line.clear();
}
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"))
}
}

View File

@@ -1,14 +1,27 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
// filepath: src-tauri/src/lib.rs
mod ytdlp;
mod downloader;
mod storage;
mod commands;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
commands::init_ytdlp,
commands::update_ytdlp,
commands::get_ytdlp_version,
commands::fetch_metadata,
commands::start_download,
commands::get_settings,
commands::save_settings,
commands::get_history,
commands::clear_history,
commands::delete_history_item,
commands::open_in_explorer
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

116
src-tauri/src/storage.rs Normal file
View File

@@ -0,0 +1,116 @@
// filepath: src-tauri/src/storage.rs
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
use anyhow::Result;
use chrono::{DateTime, Utc};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Settings {
pub download_path: String,
pub theme: String, // 'light', 'dark', 'system'
pub last_updated: Option<DateTime<Utc>>,
}
impl Default for Settings {
fn default() -> Self {
// We'll resolve the actual download path at runtime if empty,
// but for default struct we can keep it empty or a placeholder.
Self {
download_path: "".to_string(),
theme: "system".to_string(),
last_updated: None,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct HistoryItem {
pub id: String,
pub title: String,
pub thumbnail: String,
pub url: String,
pub output_path: String,
pub timestamp: DateTime<Utc>,
pub status: String, // "success", "failed"
pub format: String,
}
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()?;
if !path.exists() {
fs::create_dir_all(&path)?;
}
Ok(path)
}
pub fn get_settings_path(app: &AppHandle) -> Result<PathBuf> {
Ok(get_app_data_dir(app)?.join("settings.json"))
}
pub fn get_history_path(app: &AppHandle) -> Result<PathBuf> {
Ok(get_app_data_dir(app)?.join("history.json"))
}
pub fn load_settings(app: &AppHandle) -> Result<Settings> {
let path = get_settings_path(app)?;
if path.exists() {
let content = fs::read_to_string(&path)?;
let settings: Settings = serde_json::from_str(&content)?;
return Ok(settings);
}
// If not exists, return default.
// Note: We might want to set a default download path here if possible.
let mut settings = Settings::default();
if let Ok(download_dir) = app.path().download_dir() {
settings.download_path = download_dir.to_string_lossy().to_string();
}
Ok(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)?;
Ok(())
}
pub fn load_history(app: &AppHandle) -> Result<Vec<HistoryItem>> {
let path = get_history_path(app)?;
if path.exists() {
let content = fs::read_to_string(&path)?;
let history: Vec<HistoryItem> = serde_json::from_str(&content)?;
Ok(history)
} else {
Ok(Vec::new())
}
}
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)?;
Ok(())
}
pub fn add_history_item(app: &AppHandle, item: HistoryItem) -> Result<()> {
let mut history = load_history(app)?;
// Prepend
history.insert(0, item);
save_history(app, &history)?;
Ok(())
}
pub fn clear_history(app: &AppHandle) -> Result<()> {
save_history(app, &[])
}
pub fn delete_history_item(app: &AppHandle, id: &str) -> Result<()> {
let mut history = load_history(app)?;
history.retain(|item| item.id != id);
save_history(app, &history)?;
Ok(())
}

140
src-tauri/src/ytdlp.rs Normal file
View File

@@ -0,0 +1,140 @@
// filepath: src-tauri/src/ytdlp.rs
use std::fs;
use std::path::PathBuf;
use tauri::AppHandle;
use anyhow::{Result, anyhow};
#[cfg(target_family = "unix")]
use std::os::unix::fs::PermissionsExt;
use crate::storage::{self};
const REPO_URL: &str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download";
pub fn get_binary_name() -> &'static str {
if cfg!(target_os = "windows") {
"yt-dlp.exe"
} else if cfg!(target_os = "macos") {
"yt-dlp_macos"
} else {
"yt-dlp"
}
}
// Helper for testing
pub fn get_ytdlp_path_from_dir(base_dir: &PathBuf) -> PathBuf {
base_dir.join("bin").join(get_binary_name())
}
pub fn get_ytdlp_path(app: &AppHandle) -> Result<PathBuf> {
let app_data = storage::get_app_data_dir(app)?;
// Use helper
Ok(get_ytdlp_path_from_dir(&app_data))
}
pub fn check_ytdlp(app: &AppHandle) -> bool {
match get_ytdlp_path(app) {
Ok(path) => path.exists(),
Err(_) => false,
}
}
pub async fn download_ytdlp(app: &AppHandle) -> Result<PathBuf> {
let binary_name = get_binary_name();
let url = format!("{}/{}", REPO_URL, binary_name);
let response = reqwest::get(&url).await?;
if !response.status().is_success() {
return Err(anyhow!("Failed to download yt-dlp: Status {}", response.status()));
}
let bytes = response.bytes().await?;
let path = get_ytdlp_path(app)?;
// Ensure parent directory exists
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, bytes)?;
// Set permissions on Unix
#[cfg(target_family = "unix")]
{
let mut perms = fs::metadata(&path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&path, perms)?;
}
// Update settings with timestamp
let mut settings = storage::load_settings(app)?;
settings.last_updated = Some(chrono::Utc::now());
storage::save_settings(app, &settings)?;
Ok(path)
}
pub async fn update_ytdlp(app: &AppHandle) -> Result<String> {
let path = get_ytdlp_path(app)?;
if !path.exists() {
download_ytdlp(app).await?;
return Ok("Downloaded fresh copy".to_string());
}
let output = std::process::Command::new(&path)
.arg("-U")
.output()?;
if output.status.success() {
// Update settings timestamp
let mut settings = storage::load_settings(app)?;
settings.last_updated = Some(chrono::Utc::now());
storage::save_settings(app, &settings)?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
Ok(stdout)
} else {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
Err(anyhow!("Update failed: {}", stderr))
}
}
pub fn get_version(app: &AppHandle) -> Result<String> {
let path = get_ytdlp_path(app)?;
if !path.exists() {
return Ok("Not installed".to_string());
}
let output = std::process::Command::new(&path)
.arg("--version")
.output()?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
Ok("Unknown".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_binary_name() {
let name = get_binary_name();
if cfg!(target_os = "windows") {
assert_eq!(name, "yt-dlp.exe");
} else if cfg!(target_os = "macos") {
assert_eq!(name, "yt-dlp_macos");
}
}
#[test]
fn test_path_construction() {
let base = PathBuf::from("/tmp/app");
let path = get_ytdlp_path_from_dir(&base);
let name = get_binary_name();
assert_eq!(path, base.join("bin").join(name));
}
}