From 088def2ee4b7d5070da23b227a0d495436bcd5fe Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 21 Sep 2026 11:00:46 -0400 Subject: [PATCH] feat: add background download manager with progress reporting Co-authored-by: Cursor --- src-tauri/src/commands/video.rs | 78 +++++++++++++++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/services/download_manager.rs | 72 +++++++++++++++++++- src/lib/bindings/video.ts | 43 +++++++++++- src/lib/components/StatusBar.svelte | 53 ++++++++++++++- src/lib/components/UrlInput.svelte | 9 ++- src/lib/stores/videoSession.svelte.ts | 41 ++++++++++++ 7 files changed, 292 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands/video.rs b/src-tauri/src/commands/video.rs index a511930..b8a018a 100644 --- a/src-tauri/src/commands/video.rs +++ b/src-tauri/src/commands/video.rs @@ -1,5 +1,8 @@ use crate::models::{CookieSource, VideoMetadata}; -use crate::services::video_resolver; +use crate::services::{download_manager, video_resolver}; +use serde::Serialize; +use std::io::{BufRead, BufReader}; +use tauri::ipc::Channel; #[tauri::command] pub async fn resolve_url( @@ -10,3 +13,76 @@ pub async fn resolve_url( .await .map_err(|e| format!("Task failed: {e}"))? } + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "event", content = "data")] +pub enum DownloadEvent { + Progress { percent: f64 }, + FilePath { path: String }, + Finished { success: bool, path: String }, + Error { message: String }, +} + +#[tauri::command] +pub async fn start_download( + url: String, + cookie_source: CookieSource, + on_event: Channel, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + let temp_dir = std::env::temp_dir() + .join("video-clipper") + .to_string_lossy() + .to_string(); + std::fs::create_dir_all(&temp_dir) + .map_err(|e| format!("Failed to create temp dir: {e}"))?; + + let (mut child, _template) = + download_manager::start_download(&url, &cookie_source, &temp_dir)?; + + let Some(stderr) = child.stderr.take() else { + let status = child + .wait() + .map_err(|e| format!("Download failed: {e}"))?; + let _ = on_event.send(DownloadEvent::Finished { + success: status.success(), + path: String::new(), + }); + return Ok(()); + }; + + let reader = BufReader::new(stderr); + let mut last_file_path: Option = None; + + for line in reader.lines().map_while(Result::ok) { + if let Some(pct) = download_manager::parse_progress_line(&line) { + let _ = on_event.send(DownloadEvent::Progress { percent: pct }); + } + + if line.contains("[download] Destination:") + || line.contains("[Merger] Merging formats into") + { + if let Some(path) = line.split(": ").nth(1) { + let trimmed = path.trim().trim_matches('"').to_string(); + let _ = on_event.send(DownloadEvent::FilePath { + path: trimmed.clone(), + }); + last_file_path = Some(trimmed); + } + } + } + + let status = child + .wait() + .map_err(|e| format!("Download failed: {e}"))?; + let path = last_file_path.unwrap_or_default(); + let _ = on_event.send(DownloadEvent::Finished { + success: status.success(), + path, + }); + + Ok(()) + }) + .await + .map_err(|e| format!("Task failed: {e}"))? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8fe41b4..ec78dc4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ pub fn run() { dependencies::check_dependencies, dependencies::install_dependency, video::resolve_url, + video::start_download, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/services/download_manager.rs b/src-tauri/src/services/download_manager.rs index e97f05f..239f8f9 100644 --- a/src-tauri/src/services/download_manager.rs +++ b/src-tauri/src/services/download_manager.rs @@ -1 +1,71 @@ -// Implemented in Task 5 +use crate::models::CookieSource; +use std::process::{Child, Command, Stdio}; + +pub fn parse_progress_line(line: &str) -> Option { + if !line.contains("[download]") || !line.contains('%') { + return None; + } + let pct_str = line.split_whitespace().find(|s| s.ends_with('%'))?; + pct_str + .trim_end_matches('%') + .parse::() + .ok() + .map(|p| p / 100.0) +} + +pub fn start_download( + url: &str, + cookie_source: &CookieSource, + temp_dir: &str, +) -> Result<(Child, String), String> { + let output_template = format!("{}/%(title)s.%(ext)s", temp_dir); + + let mut cmd = Command::new("yt-dlp"); + cmd.arg("-o") + .arg(&output_template) + .arg("--newline") + .arg("--no-part") + .arg("-c"); + + for arg in cookie_source.to_ytdlp_args() { + cmd.arg(arg); + } + + cmd.arg(url) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let child = cmd + .spawn() + .map_err(|e| format!("Failed to start yt-dlp: {e}"))?; + + Ok((child, output_template)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_progress_percentage() { + let line = "[download] 45.2% of ~100.00MiB at 5.00MiB/s ETA 00:10"; + assert!((parse_progress_line(line).unwrap() - 0.452).abs() < 0.001); + } + + #[test] + fn test_parse_progress_100_percent() { + let line = "[download] 100% of 50.00MiB in 00:05"; + assert!((parse_progress_line(line).unwrap() - 1.0).abs() < 0.001); + } + + #[test] + fn test_parse_progress_non_progress_line() { + let line = "[youtube] Extracting URL: https://www.youtube.com/watch?v=abc"; + assert!(parse_progress_line(line).is_none()); + } + + #[test] + fn test_parse_progress_empty_line() { + assert!(parse_progress_line("").is_none()); + } +} diff --git a/src/lib/bindings/video.ts b/src/lib/bindings/video.ts index 83a6c72..d5c0463 100644 --- a/src/lib/bindings/video.ts +++ b/src/lib/bindings/video.ts @@ -1,4 +1,4 @@ -import { invoke } from '@tauri-apps/api/core'; +import { invoke, Channel } from '@tauri-apps/api/core'; import { preferences } from '$lib/stores/preferences.svelte'; export interface VideoMetadata { @@ -10,9 +10,50 @@ export interface VideoMetadata { streamUrl: string; } +type DownloadEvent = + | { event: 'progress'; data: { percent: number } } + | { event: 'filePath'; data: { path: string } } + | { event: 'finished'; data: { success: boolean; path: string } } + | { event: 'error'; data: { message: string } }; + export async function resolveUrl(url: string): Promise { return invoke('resolve_url', { url, cookieSource: preferences.cookieSource, }); } + +export async function startDownload( + url: string, + onProgress: (percent: number) => void, + onFilePath: (path: string) => void, + onFinished: (success: boolean, path: string) => void, + onError: (message: string) => void +): Promise { + const onEvent = new Channel(); + onEvent.onmessage = (message) => { + switch (message.event) { + case 'progress': + onProgress(message.data.percent); + break; + case 'filePath': + onFilePath(message.data.path); + break; + case 'finished': + onFinished(message.data.success, message.data.path); + break; + case 'error': + onError(message.data.message); + break; + default: { + const _exhaustive: never = message; + throw new Error(`Unhandled download event: ${(_exhaustive as DownloadEvent).event}`); + } + } + }; + await invoke('start_download', { + url, + cookieSource: preferences.cookieSource, + onEvent, + }); +} diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 8270dc9..a6fd9b0 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -1,5 +1,36 @@ + +
- Ready + {#if isDownloading} +
+
+
+ Downloading: {progressPercent}% + {:else if isDownloaded} + ✓ Downloaded: {session.title} + {:else if session.status === 'resolving'} + Resolving URL… + {:else if session.status === 'ready' && session.downloadStatus === 'downloading'} + Starting download… + {:else if session.downloadStatus === 'failed'} + Download failed + {:else} + Ready + {/if}
diff --git a/src/lib/components/UrlInput.svelte b/src/lib/components/UrlInput.svelte index ca2af36..9b4c3d3 100644 --- a/src/lib/components/UrlInput.svelte +++ b/src/lib/components/UrlInput.svelte @@ -1,6 +1,12 @@