diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6605e23..db8e3ed 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -281,6 +281,18 @@ fn build_media_format_options(formats_arr: &[serde_json::Value]) -> Vec, + downloaded_bytes: Option, } fn progress_json_number(progress: &serde_json::Value, key: &str) -> Option { @@ -422,6 +435,7 @@ fn parse_media_progress_line(line: &str) -> Option { speed, eta, size, + downloaded_bytes: (downloaded > 0.0).then_some(downloaded), }); } @@ -452,6 +466,7 @@ fn parse_media_progress_line(line: &str) -> Option { speed, eta, size, + downloaded_bytes: None, }); } @@ -477,9 +492,39 @@ fn parse_media_progress_line(line: &str) -> Option { .map(|value| value.as_str().to_string()) .unwrap_or_else(|| "-".to_string()), size: None, + downloaded_bytes: None, }) } +fn media_progress_speed(progress: &MediaProgress, now: Instant, last_sample: &mut Option<(Instant, f64)>) -> String { + let Some(downloaded_bytes) = progress.downloaded_bytes else { + return progress.speed.clone(); + }; + + let Some((last_at, last_bytes)) = *last_sample else { + *last_sample = Some((now, downloaded_bytes)); + return progress.speed.clone(); + }; + + *last_sample = Some((now, downloaded_bytes)); + + if downloaded_bytes < last_bytes { + return progress.speed.clone(); + } + + let elapsed = now.duration_since(last_at).as_secs_f64(); + if elapsed <= 0.05 { + return progress.speed.clone(); + } + + let bytes_per_second = (downloaded_bytes - last_bytes) / elapsed; + if bytes_per_second > 0.0 { + crate::download::format_speed(bytes_per_second) + } else { + progress.speed.clone() + } +} + async fn cleanup_media_processing_artifacts(out_path: &std::path::Path) { let Some(parent) = out_path.parent() else { return; @@ -1845,6 +1890,7 @@ pub(crate) async fn start_media_download_internal( let _keep_alive = config_path; let mut current_track: f64 = 0.0; let mut last_fraction: f64 = 0.0; + let mut last_speed_sample: Option<(Instant, f64)> = None; let mut last_progress_at = std::time::Instant::now() .checked_sub(std::time::Duration::from_millis(200)) .unwrap_or_else(std::time::Instant::now); @@ -1969,19 +2015,21 @@ pub(crate) async fn start_media_download_internal( Some(tauri_plugin_shell::process::CommandEvent::Stdout(line_bytes)) => { let line = String::from_utf8_lossy(&line_bytes); if let Some(progress) = parse_media_progress_line(&line) { - if progress.fraction < last_fraction && (last_fraction - progress.fraction) > 0.5 { - current_track += 1.0; - } - last_fraction = progress.fraction; + if progress.fraction < last_fraction && (last_fraction - progress.fraction) > 0.5 { + current_track += 1.0; + last_speed_sample = None; + } + last_fraction = progress.fraction; + let speed = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample); - let overall_fraction = ((current_track + progress.fraction) / total_tracks).min(1.0); + let overall_fraction = ((current_track + progress.fraction) / total_tracks).min(1.0); let now = std::time::Instant::now(); if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(200) { let _ = app_handle.emit("download-progress", DownloadProgressEvent { id: id.to_string(), fraction: overall_fraction, - speed: progress.speed, + speed, eta: progress.eta, size: progress.size, }); @@ -1994,19 +2042,21 @@ pub(crate) async fn start_media_download_internal( if let Some(progress) = parse_media_progress_line(&line) { if progress.fraction < last_fraction && (last_fraction - progress.fraction) > 0.5 { current_track += 1.0; + last_speed_sample = None; } last_fraction = progress.fraction; + let speed = media_progress_speed(&progress, std::time::Instant::now(), &mut last_speed_sample); let overall_fraction = ((current_track + progress.fraction) / total_tracks).min(1.0); let now = std::time::Instant::now(); if now.duration_since(last_progress_at) >= std::time::Duration::from_millis(200) { let _ = app_handle.emit("download-progress", DownloadProgressEvent { - id: id.to_string(), - fraction: overall_fraction, - speed: progress.speed, - eta: progress.eta, - size: progress.size, - }); + id: id.to_string(), + fraction: overall_fraction, + speed, + eta: progress.eta, + size: progress.size, + }); last_progress_at = now; } } @@ -2740,9 +2790,11 @@ fn set_extension_frontend_ready( mod tests { use super::{ build_media_format_options, collect_download_uris, is_excluded_yt_dlp_format, json_lower, - parse_firelink_urls, parse_media_progress_line, MediaProgress, MEDIA_PROGRESS_PREFIX, + media_progress_speed, parse_firelink_urls, parse_media_progress_line, MediaProgress, + MEDIA_PROGRESS_PREFIX, }; use serde_json::json; + use std::time::{Duration, Instant}; #[test] fn collects_primary_url_and_unique_mirrors_in_order() { @@ -2837,12 +2889,18 @@ mod tests { let options = build_media_format_options(&formats); - assert!(!options.iter().any(|format| format.ext == "mhtml")); - assert!(options.iter().any(|format| { - format.resolution == "1080p" - && format.ext == "mp4" - && format.format_label == "MP4 • H.264 + AAC" - && format.filesize == Some(110_000_000) + assert!(!options.iter().any(|format| format.ext == "mhtml")); + assert!(options.iter().any(|format| { + format.resolution == "1080p" + && format.ext == "mkv" + && format.format_label == "MKV • H.264 + AAC" + && format.filesize == Some(110_000_000) + })); + assert!(options.iter().any(|format| { + format.resolution == "1080p" + && format.ext == "mp4" + && format.format_label == "MP4 • H.264 + AAC" + && format.filesize == Some(110_000_000) })); assert!(options.iter().any(|format| { format.resolution == "Audio only" && format.format_label == "M4A • AAC" @@ -2917,10 +2975,37 @@ mod tests { speed: "1.00MiB/s".to_string(), eta: "00:05".to_string(), size: Some("10.00MiB".to_string()), + downloaded_bytes: Some(5242880.0), }) ); } + #[test] + fn derives_main_window_speed_from_downloaded_byte_delta() { + let first = MediaProgress { + fraction: 0.25, + speed: "fallback".to_string(), + eta: "-".to_string(), + size: None, + downloaded_bytes: Some(1_000_000.0), + }; + let second = MediaProgress { + fraction: 0.5, + speed: "fallback".to_string(), + eta: "-".to_string(), + size: None, + downloaded_bytes: Some(3_097_152.0), + }; + let start = Instant::now(); + let mut sample = None; + + assert_eq!(media_progress_speed(&first, start, &mut sample), "fallback"); + assert_eq!( + media_progress_speed(&second, start + Duration::from_secs(1), &mut sample), + "2.0 MB/s" + ); + } + #[test] fn parses_aria2_external_downloader_progress() { let line = "[#2d2636 12MiB/34MiB(34%) CN:1 DL:910KiB ETA:25s]"; @@ -2932,6 +3017,7 @@ mod tests { speed: "910KiB/s".to_string(), eta: "25s".to_string(), size: Some("34MiB".to_string()), + downloaded_bytes: None, }) ); } @@ -2947,6 +3033,7 @@ mod tests { speed: "2.00MiB/s".to_string(), eta: "00:03".to_string(), size: None, + downloaded_bytes: None, }) ); } diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index a7a9f0e..b2ae570 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -379,13 +379,14 @@ export const AddDownloadsModal = () => { password: keychainPassword }); if (mediaData && mediaData.formats.length > 0) { - const mappedFormats = mediaData.formats.map(f => { - const quality = f.resolution || 'Best'; - const bytes = f.filesize || 0; - return { - name: quality, - ext: f.ext, - bytes, + const mappedFormats = mediaData.formats.map(f => { + const quality = f.resolution || 'Best'; + const container = f.ext.toUpperCase(); + const bytes = f.filesize || 0; + return { + name: `${quality} ${container}`, + ext: f.ext, + bytes, formatLabel: f.format_label || f.ext.toUpperCase(), detail: bytes ? formatBytes(bytes) : 'Unknown size', selector: f.format_id,