From 1d197432b2e6741f0baca8fbf5d88ec96aa146f8 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 15 Jul 2026 20:35:05 +0330 Subject: [PATCH] feat(downloads): add live limits clipboard capture and byte progress --- src-tauri/src/ipc.rs | 8 ++ src-tauri/src/lib.rs | 187 ++++++++++++++++++++++++-- src-tauri/src/settings.rs | 4 +- src/App.tsx | 77 +++++++++++ src/bindings/DownloadItem.ts | 2 +- src/bindings/DownloadProgressEvent.ts | 2 +- src/bindings/PersistedSettings.ts | 2 +- src/components/DownloadItem.tsx | 31 ++++- src/components/PropertiesModal.tsx | 31 ++++- src/components/SettingsView.tsx | 12 ++ src/store/downloadStore.ts | 9 ++ src/store/useDownloadStore.test.ts | 33 ++++- src/store/useDownloadStore.ts | 19 ++- src/store/useSettingsStore.ts | 9 ++ src/utils/downloadProgress.test.ts | 23 ++++ src/utils/downloadProgress.ts | 61 +++++++++ src/utils/downloads.test.ts | 33 +++++ src/utils/downloads.ts | 18 ++- 18 files changed, 536 insertions(+), 25 deletions(-) create mode 100644 src/utils/downloadProgress.test.ts create mode 100644 src/utils/downloadProgress.ts create mode 100644 src/utils/downloads.test.ts diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index ef562e6..b909101 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -80,6 +80,12 @@ pub struct DownloadItem { pub eta: Option, #[ts(optional)] pub size: Option, + #[ts(optional)] + pub downloaded_bytes: Option, + #[ts(optional)] + pub total_bytes: Option, + #[ts(optional)] + pub total_is_estimate: Option, pub category: DownloadCategory, pub date_added: String, #[ts(optional)] @@ -274,6 +280,8 @@ pub struct PersistedSettings { pub max_automatic_retries: i32, pub show_notifications: bool, pub play_completion_sound: bool, + #[serde(default)] + pub auto_add_clipboard_links: bool, pub app_font_size: AppFontSize, pub list_row_density: ListRowDensity, pub show_dock_badge: bool, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 901a59c..cd34842 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -827,6 +827,8 @@ struct MediaProgress { eta: String, size: Option, downloaded_bytes: Option, + total_bytes: Option, + total_is_estimate: bool, } fn progress_json_number(progress: &serde_json::Value, key: &str) -> Option { @@ -928,13 +930,17 @@ fn parse_media_progress_line(line: &str) -> Option { let size = progress_json_string(&progress, "_total_bytes_str") .or_else(|| progress_json_string(&progress, "_total_bytes_estimate_str")) .or_else(|| (total > 0.0).then(|| crate::download::format_size(total))); + let total_is_estimate = progress.get("total_bytes").is_none() + && progress.get("total_bytes_estimate").is_some(); return Some(MediaProgress { fraction: fraction.clamp(0.0, 1.0), speed, eta, size, - downloaded_bytes: (downloaded > 0.0).then_some(downloaded), + downloaded_bytes: (total > 0.0 || downloaded > 0.0).then_some(downloaded), + total_bytes: (total > 0.0).then_some(total), + total_is_estimate, }); } @@ -961,12 +967,20 @@ fn parse_media_progress_line(line: &str) -> Option { .map(|value| value.as_str().to_string()) .unwrap_or_else(|| "-".to_string()); let size = captures.get(2).map(|value| value.as_str().to_string()); + let downloaded_bytes = captures + .get(1) + .and_then(|value| parse_human_size(value.as_str())); + let total_bytes = captures + .get(2) + .and_then(|value| parse_human_size(value.as_str())); return Some(MediaProgress { fraction: fraction.clamp(0.0, 1.0), speed, eta, size, - downloaded_bytes: None, + downloaded_bytes, + total_bytes, + total_is_estimate: false, }); } @@ -976,16 +990,24 @@ fn parse_media_progress_line(line: &str) -> Option { let fraction = captures.get(1)?.as_str().parse::().ok()? / 100.0; let speed_re = YTDLP_SPD_RE.get_or_init(|| Regex::new(r"\bat\s+([^\s]+)").unwrap()); let eta_re = YTDLP_ETA_RE.get_or_init(|| Regex::new(r"\bETA\s+([^\s]+)").unwrap()); - let size_re = YTDLP_SIZE_RE.get_or_init(|| Regex::new(r"of\s+~?\s*([0-9.]+[a-zA-Z]+)").unwrap()); + let size_re = YTDLP_SIZE_RE + .get_or_init(|| Regex::new(r"of\s+(~?)\s*([0-9.]+[a-zA-Z]+)").unwrap()); let mut parsed_size_str = None; let mut downloaded_bytes = None; + let mut total_bytes = None; + let mut total_is_estimate = false; if let Some(captures) = size_re.captures(line) { - if let Some(size_str) = captures.get(1) { + if let Some(size_str) = captures.get(2) { parsed_size_str = Some(size_str.as_str().to_string()); - if let Some(total_bytes) = parse_human_size(size_str.as_str()) { - downloaded_bytes = Some(total_bytes * fraction); + if let Some(parsed_total_bytes) = parse_human_size(size_str.as_str()) { + downloaded_bytes = Some(parsed_total_bytes * fraction); + total_bytes = Some(parsed_total_bytes); } } + total_is_estimate = captures + .get(1) + .map(|marker| !marker.as_str().is_empty()) + .unwrap_or(false); } Some(MediaProgress { @@ -1002,6 +1024,8 @@ fn parse_media_progress_line(line: &str) -> Option { .unwrap_or_else(|| "-".to_string()), size: parsed_size_str, downloaded_bytes, + total_bytes, + total_is_estimate, }) } @@ -1110,6 +1134,12 @@ struct MediaProgressEmitterState { last_fraction: f64, speed_sampler: MediaSpeedSampler, last_progress_at: Instant, + completed_tracks_downloaded_bytes: Option, + completed_tracks_total_bytes: Option, + completed_tracks_total_is_estimate: bool, + current_track_downloaded_bytes: Option, + current_track_total_bytes: Option, + current_track_total_is_estimate: bool, } impl MediaProgressEmitterState { @@ -1121,10 +1151,64 @@ impl MediaProgressEmitterState { last_progress_at: Instant::now() .checked_sub(MEDIA_PROGRESS_EMIT_INTERVAL) .unwrap_or_else(Instant::now), + completed_tracks_downloaded_bytes: Some(0.0), + completed_tracks_total_bytes: Some(0.0), + completed_tracks_total_is_estimate: false, + current_track_downloaded_bytes: None, + current_track_total_bytes: None, + current_track_total_is_estimate: false, } } } +fn aggregate_media_byte_progress( + progress: &MediaProgress, + track_changed: bool, + state: &mut MediaProgressEmitterState, +) -> Option<(u64, u64, bool)> { + if track_changed { + if let Some(total_bytes) = state.current_track_total_bytes { + *state + .completed_tracks_total_bytes + .get_or_insert(0.0) += total_bytes; + *state + .completed_tracks_downloaded_bytes + .get_or_insert(0.0) += total_bytes; + state.completed_tracks_total_is_estimate |= state.current_track_total_is_estimate; + } else { + state.completed_tracks_total_bytes = None; + state.completed_tracks_downloaded_bytes = None; + } + state.current_track_downloaded_bytes = None; + state.current_track_total_bytes = None; + state.current_track_total_is_estimate = false; + } + state.current_track_total_bytes = progress.total_bytes; + state.current_track_downloaded_bytes = progress + .downloaded_bytes + .or_else(|| progress.total_bytes.map(|total| total * progress.fraction)); + state.current_track_total_is_estimate = progress.total_is_estimate; + + match ( + state.completed_tracks_downloaded_bytes, + state.completed_tracks_total_bytes, + state.current_track_downloaded_bytes, + state.current_track_total_bytes, + ) { + ( + Some(completed_downloaded), + Some(completed_total), + Some(current_downloaded), + Some(current_total), + ) if current_total > 0.0 => Some(( + (completed_downloaded + current_downloaded).max(0.0).round() as u64, + (completed_total + current_total).max(0.0).round() as u64, + state.completed_tracks_total_is_estimate || state.current_track_total_is_estimate, + )), + _ => None, + } +} + fn emit_media_progress( app_handle: &tauri::AppHandle, id: &str, @@ -1139,9 +1223,11 @@ fn emit_media_progress( &mut state.last_fraction, progress.fraction, ); - if state.current_track != previous_track { + let track_changed = state.current_track != previous_track; + if track_changed { state.speed_sampler.reset(); } + let byte_progress = aggregate_media_byte_progress(&progress, track_changed, state); let (speed, eta) = media_progress_speed(&progress, Instant::now(), &mut state.speed_sampler); let now = Instant::now(); @@ -1155,6 +1241,9 @@ fn emit_media_progress( eta, size: progress.size, size_is_final: false, + downloaded_bytes: byte_progress.map(|value| value.0 as f64), + total_bytes: byte_progress.map(|value| value.1 as f64), + total_is_estimate: byte_progress.map(|value| value.2), }, ); state.last_progress_at = now; @@ -2169,6 +2258,12 @@ pub struct DownloadProgressEvent { eta: String, size: Option, size_is_final: bool, + #[ts(optional)] + downloaded_bytes: Option, + #[ts(optional)] + total_bytes: Option, + #[ts(optional)] + total_is_estimate: Option, } #[derive(Debug, Clone, Serialize, TS)] @@ -3271,6 +3366,9 @@ pub(crate) async fn start_media_download_internal( eta: "-".to_string(), size: None, size_is_final: false, + downloaded_bytes: None, + total_bytes: None, + total_is_estimate: Some(false), }); } let lower = line.to_lowercase(); @@ -3339,6 +3437,9 @@ pub(crate) async fn start_media_download_internal( eta: "-".to_string(), size: Some(crate::download::format_size(metadata.len() as f64)), size_is_final: true, + downloaded_bytes: Some(metadata.len() as f64), + total_bytes: Some(metadata.len() as f64), + total_is_estimate: Some(false), }); } } @@ -5577,7 +5678,8 @@ fn set_extension_frontend_ready(state: tauri::State<'_, AppState>, ready: bool) #[cfg(test)] mod tests { use super::{ - aggregate_media_fraction, append_ytdlp_config_option, append_ytdlp_http_headers, + aggregate_media_byte_progress, aggregate_media_fraction, append_ytdlp_config_option, + append_ytdlp_http_headers, build_media_format_options, collect_download_uris, drain_media_output_lines, filename_from_content_disposition, filename_from_url_disposition_query, filename_from_url_path, is_excluded_yt_dlp_format, @@ -5591,7 +5693,7 @@ mod tests { retry_metadata_with_cookies, should_retry_metadata_with_cookies, should_send_metadata_credentials, collect_log_files, FirelinkDeepLink, percent_decode_metadata_value, MediaProgress, - MediaSpeedSampler, MEDIA_PROGRESS_PREFIX, + MediaProgressEmitterState, MediaSpeedSampler, MEDIA_PROGRESS_PREFIX, }; use serde_json::json; use std::time::{Duration, Instant}; @@ -6314,6 +6416,8 @@ mod tests { eta: "00:05".to_string(), size: Some("10.00MiB".to_string()), downloaded_bytes: Some(5242880.0), + total_bytes: Some(10485760.0), + total_is_estimate: false, }) ); } @@ -6400,6 +6504,50 @@ mod tests { assert_eq!(current_track, 1.0); } + #[test] + fn aggregates_media_byte_progress_across_tracks() { + let mut state = MediaProgressEmitterState::new(); + let first = MediaProgress { + fraction: 0.99, + speed: "-".to_string(), + eta: "-".to_string(), + size: Some("100B".to_string()), + downloaded_bytes: Some(99.0), + total_bytes: Some(100.0), + total_is_estimate: false, + }; + let second = MediaProgress { + fraction: 0.01, + speed: "-".to_string(), + eta: "-".to_string(), + size: Some("200B".to_string()), + downloaded_bytes: Some(2.0), + total_bytes: Some(200.0), + total_is_estimate: true, + }; + + aggregate_media_fraction( + 2.0, + &mut state.current_track, + &mut state.last_fraction, + first.fraction, + ); + assert_eq!( + aggregate_media_byte_progress(&first, false, &mut state), + Some((99, 100, false)) + ); + aggregate_media_fraction( + 2.0, + &mut state.current_track, + &mut state.last_fraction, + second.fraction, + ); + assert_eq!( + aggregate_media_byte_progress(&second, true, &mut state), + Some((102, 300, true)) + ); + } + #[test] fn derives_main_window_speed_from_downloaded_byte_delta() { let first = MediaProgress { @@ -6408,6 +6556,8 @@ mod tests { eta: "-".to_string(), size: None, downloaded_bytes: Some(1_000_000.0), + total_bytes: None, + total_is_estimate: false, }; let second = MediaProgress { fraction: 0.5, @@ -6415,6 +6565,8 @@ mod tests { eta: "-".to_string(), size: None, downloaded_bytes: Some(3_097_152.0), + total_bytes: None, + total_is_estimate: false, }; let start = Instant::now(); let mut sampler = MediaSpeedSampler::default(); @@ -6439,6 +6591,8 @@ mod tests { eta: "-".to_string(), size: None, downloaded_bytes: Some(1_000_000.0), + total_bytes: None, + total_is_estimate: false, }; assert_eq!( @@ -6471,14 +6625,16 @@ mod tests { speed: "910KiB/s".to_string(), eta: "25s".to_string(), size: Some("34MiB".to_string()), - downloaded_bytes: None, + downloaded_bytes: Some(12.0 * 1024.0 * 1024.0), + total_bytes: Some(34.0 * 1024.0 * 1024.0), + total_is_estimate: false, }) ); } #[test] fn retains_legacy_ytdlp_progress_fallback() { - let line = "[download] 42.5% of 10.00MiB at 2.00MiB/s ETA 00:03"; + let line = "[download] 42.5% of ~10.00MiB at 2.00MiB/s ETA 00:03"; assert_eq!( parse_media_progress_line(line), @@ -6488,6 +6644,8 @@ mod tests { eta: "00:03".to_string(), size: Some("10.00MiB".to_string()), downloaded_bytes: Some(4456448.0), + total_bytes: Some(10485760.0), + total_is_estimate: true, }) ); } @@ -7043,9 +7201,12 @@ pub fn run() { id: id.clone(), fraction, speed, - eta, - size, + eta, + size, size_is_final: false, + downloaded_bytes: Some(completed as f64), + total_bytes: (total > 0).then_some(total as f64), + total_is_estimate: Some(false), }); if should_refresh { diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 27fd21f..43166fa 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -441,6 +441,7 @@ fn default_settings() -> PersistedSettings { max_automatic_retries: 3, show_notifications: true, play_completion_sound: false, + auto_add_clipboard_links: false, app_font_size: AppFontSize::Standard, list_row_density: ListRowDensity::Standard, show_dock_badge: true, @@ -696,8 +697,9 @@ mod tests { } #[test] - fn completion_sound_default_matches_the_frontend_default() { + fn opt_in_defaults_match_the_frontend_defaults() { assert!(!default_settings().play_completion_sound); + assert!(!default_settings().auto_add_clipboard_links); } #[test] diff --git a/src/App.tsx b/src/App.tsx index b19ff57..010ab1a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import SettingsView from "./components/SettingsView"; import { PropertiesModal } from "./components/PropertiesModal"; import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal"; import { extractValidDownloadUrls } from './utils/url'; +import { readClipboardDownloadUrls } from './utils/clipboard'; import { listenEvent as listen, invokeCommand as invoke } from "./ipc"; import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore'; import { initDownloadListener } from './store/downloadStore'; @@ -110,6 +111,7 @@ function App() { const appFontSize = useSettingsStore(state => state.appFontSize); const listRowDensity = useSettingsStore(state => state.listRowDensity); const autoCheckUpdates = useSettingsStore(state => state.autoCheckUpdates); + const autoAddClipboardLinks = useSettingsStore(state => state.autoAddClipboardLinks); const showNotifications = useSettingsStore(state => state.showNotifications); const showDockBadge = useSettingsStore(state => state.showDockBadge); const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon); @@ -695,6 +697,81 @@ function App() { return () => window.removeEventListener('paste', handlePaste); }, []); + useEffect(() => { + if (!coreReady || !autoAddClipboardLinks) return; + + let active = true; + let wasForeground = false; + let readInFlight = false; + let lastClipboardKey: string | null = null; + + const isForeground = () => + document.visibilityState === 'visible' && + (typeof document.hasFocus !== 'function' || document.hasFocus()); + + const readClipboardOnForeground = async () => { + if (!active || readInFlight || !isForeground()) return; + + const storeBeforeRead = useDownloadStore.getState(); + const requestVersionBeforeRead = storeBeforeRead.pendingAddRequestVersion; + readInFlight = true; + try { + const clipboardUrls = await readClipboardDownloadUrls(); + if (!active) return; + + const currentSettings = useSettingsStore.getState(); + const currentStore = useDownloadStore.getState(); + // A user action or extension handoff won while the native clipboard + // read was pending. Let that newer Add-modal request win unchanged. + if ( + !currentSettings.autoAddClipboardLinks || + currentStore.pendingAddRequestVersion !== requestVersionBeforeRead + ) { + return; + } + + const clipboardKey = [...clipboardUrls].sort().join('\n'); + if (clipboardKey === lastClipboardKey) return; + lastClipboardKey = clipboardKey; + if (clipboardUrls.length === 0) return; + + const existingUrls = new Set(extractValidDownloadUrls(currentStore.pendingAddUrls)); + const newUrls = clipboardUrls.filter(url => !existingUrls.has(url)); + if (newUrls.length > 0) { + currentStore.openAddModalWithUrls(newUrls.join('\n')); + } + } catch (error) { + // Clipboard permissions are optional and this feature is explicitly + // opt-in, so a read failure should not interrupt normal app use. + console.warn('Automatic clipboard capture failed:', error); + } finally { + readInFlight = false; + } + }; + + const handleForegroundChange = () => { + const foreground = isForeground(); + if (!foreground) { + wasForeground = false; + return; + } + if (!wasForeground) { + wasForeground = true; + void readClipboardOnForeground(); + } + }; + + window.addEventListener('focus', handleForegroundChange); + document.addEventListener('visibilitychange', handleForegroundChange); + handleForegroundChange(); + + return () => { + active = false; + window.removeEventListener('focus', handleForegroundChange); + document.removeEventListener('visibilitychange', handleForegroundChange); + }; + }, [autoAddClipboardLinks, coreReady]); + useEffect(() => { const root = window.document.documentElement; diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index f7e539a..fdc5240 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -2,4 +2,4 @@ import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadStatus } from "./DownloadStatus"; -export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, }; +export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, }; diff --git a/src/bindings/DownloadProgressEvent.ts b/src/bindings/DownloadProgressEvent.ts index cd05f2f..c909bda 100644 --- a/src/bindings/DownloadProgressEvent.ts +++ b/src/bindings/DownloadProgressEvent.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, }; +export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, }; diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index 5595e67..62dfb02 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab"; import type { SiteLogin } from "./SiteLogin"; import type { Theme } from "./Theme"; -export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; +export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 77c2d6f..98d53a9 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -6,6 +6,10 @@ import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-rea import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem'; import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions'; import { isActiveDownloadStatus } from '../utils/downloads'; +import { + downloadProgressColorClass, + resolveDownloadSizeDisplay +} from '../utils/downloadProgress'; interface DownloadItemProps { downloadId: string; @@ -64,6 +68,13 @@ export const DownloadItem = React.memo(({ : download.status === 'processing' ? 'Muxing...' : '-'; + const sizeDisplay = resolveDownloadSizeDisplay({ + downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes, + totalBytes: liveProgress?.total_bytes ?? download.totalBytes, + totalIsEstimate: liveProgress?.total_is_estimate ?? download.totalIsEstimate, + fallbackSize: download.size + }); + const hasDownloadedAmount = Boolean(sizeDisplay.downloaded && sizeDisplay.total); return (
(({
- - {download.size && download.size !== '-' ? download.size : 'Unknown'} + + {hasDownloadedAmount ? ( + <> + {sizeDisplay.downloaded} + / + + {sizeDisplay.totalIsEstimate ? '~' : ''}{sizeDisplay.total} + + + ) : sizeDisplay.fallback}
diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index ab8a49f..b9ec3bc 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -10,6 +10,10 @@ import { isIdentityLocked as getIdentityLocked, isTransferLocked as getTransferLocked } from '../utils/downloadActions'; +import { + downloadProgressColorClass, + resolveDownloadSizeDisplay +} from '../utils/downloadProgress'; type LoginMode = 'matching' | 'custom' | 'none'; @@ -185,6 +189,13 @@ export const PropertiesModal = () => { const displayedEta = item.status === 'completed' ? '-' : liveProgress?.eta ?? item.eta ?? '-'; + const sizeDisplay = resolveDownloadSizeDisplay({ + downloadedBytes: liveProgress?.downloaded_bytes ?? item.downloadedBytes, + totalBytes: liveProgress?.total_bytes ?? item.totalBytes, + totalIsEstimate: liveProgress?.total_is_estimate ?? item.totalIsEstimate, + fallbackSize: item.size + }); + const hasDownloadedAmount = Boolean(sizeDisplay.downloaded && sizeDisplay.total); let statusColor = 'text-text-secondary'; let StatusIcon = Info; @@ -221,7 +232,25 @@ export const PropertiesModal = () => {
Progress{`${(displayedFraction * 100).toFixed(0)}%`}
-
Size{item.size || '-'}
+
+ Size + + {hasDownloadedAmount ? ( + <> + {sizeDisplay.downloaded} + / + + {sizeDisplay.totalIsEstimate ? '~' : ''}{sizeDisplay.total} + + + ) : sizeDisplay.fallback} + +
Speed{displayedSpeed}
ETA{displayedEta}
diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 253e3cb..ed4e74f 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -700,6 +700,18 @@ runEngineChecks(false); className="mac-switch" /> +
)} diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index 26edab3..9ca3eb8 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -55,6 +55,15 @@ const startDownloadListeners = async () => { if (shouldUpdateSize && current.size !== payload.size) { updates.size = payload.size!; } + if (payload.downloaded_bytes !== null && payload.downloaded_bytes !== undefined) { + updates.downloadedBytes = payload.downloaded_bytes; + } + if (payload.total_bytes !== null && payload.total_bytes !== undefined) { + updates.totalBytes = payload.total_bytes; + } + if (payload.downloaded_bytes !== null && payload.downloaded_bytes !== undefined) { + updates.totalIsEstimate = payload.total_is_estimate; + } if (Object.keys(updates).length > 0) { mainStore.updateDownload(payload.id, updates); } diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 87040aa..725ab0f 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -510,7 +510,7 @@ describe('useDownloadStore', () => { ); }); - it('uses the global speed limit only when an item has no explicit speed override', async () => { + it('does not copy the global speed limit into a normal download task', async () => { const defaultSettings = useSettingsStore.getState(); vi.mocked(useSettingsStore.getState).mockReturnValue({ ...defaultSettings, @@ -534,6 +534,37 @@ describe('useDownloadStore', () => { expect.objectContaining({ item: expect.objectContaining({ id: 'inherits-global', + speed_limit: null + }) + }) + ); + }); + + it('passes the global speed limit to a new media process when it has no item override', async () => { + const defaultSettings = useSettingsStore.getState(); + vi.mocked(useSettingsStore.getState).mockReturnValue({ + ...defaultSettings, + globalSpeedLimit: '2M' + }); + vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => { + if (cmd === 'get_pending_order') return ['inherits-global-media']; + return undefined; + }); + + await useDownloadStore.getState().addDownload({ + id: 'inherits-global-media', + url: 'https://www.youtube.com/watch?v=example', + fileName: 'media.mp4', + category: 'Movies', + dateAdded: '', + isMedia: true + }, { type: 'start-now' }); + + expect(ipc.invokeCommand).toHaveBeenCalledWith( + 'enqueue_download', + expect.objectContaining({ + item: expect.objectContaining({ + id: 'inherits-global-media', speed_limit: '2M' }) }) diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index a420290..ea1ae3f 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -134,11 +134,21 @@ const stripCookieHeaders = (value: string | null | undefined): string => .join('\n') .trim(); -const speedLimitForDispatch = (itemSpeedLimit: string | undefined, globalSpeedLimit: string): string | null => { +const explicitSpeedLimitForDispatch = (itemSpeedLimit: string | undefined): string | null => { const explicitLimit = itemSpeedLimit?.trim(); if (explicitLimit) { return normalizeSpeedLimitForBackend(explicitLimit) || (explicitLimit === '0' ? '0' : null); } + return null; +}; + +const speedLimitForDispatch = ( + itemSpeedLimit: string | undefined, + globalSpeedLimit: string, + isMedia: boolean | undefined +): string | null => { + const explicitLimit = explicitSpeedLimitForDispatch(itemSpeedLimit); + if (explicitLimit !== null || !isMedia) return explicitLimit; return normalizeSpeedLimitForBackend(globalSpeedLimit); }; @@ -186,7 +196,7 @@ export async function dispatchItem(id: string): Promise { destination, filename: item.fileName, connections: item.connections || settings.perServerConnections || null, - speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit), + speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia), username: item.username || (login ? login.username : null), password: item.password || keychainPassword, headers: item.headers || null, @@ -842,6 +852,9 @@ export const useDownloadStore = create((set, get) => ({ fraction: 0, speed: '-', eta: '-', + downloadedBytes: undefined, + totalBytes: undefined, + totalIsEstimate: undefined, hasBeenDispatched: false, dateAdded: new Date().toISOString() }); @@ -1159,7 +1172,7 @@ export const useDownloadStore = create((set, get) => ({ destination: destPath, filename: item.fileName, connections: item.connections || settings.perServerConnections || null, - speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit), + speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia), username: item.username || (login ? login.username : null), password: item.password || keychainPassword, headers: item.headers || null, diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 8f65117..b370ccb 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -143,6 +143,7 @@ export interface SettingsState { maxAutomaticRetries: number; showNotifications: boolean; playCompletionSound: boolean; + autoAddClipboardLinks: boolean; appFontSize: AppFontSize; listRowDensity: ListRowDensity; showDockBadge: boolean; @@ -186,6 +187,7 @@ export interface SettingsState { setMaxAutomaticRetries: (count: number) => void; setShowNotifications: (show: boolean) => void; setPlayCompletionSound: (play: boolean) => void; + setAutoAddClipboardLinks: (enabled: boolean) => void; setAppFontSize: (size: AppFontSize) => void; setListRowDensity: (density: ListRowDensity) => void; setShowDockBadge: (show: boolean) => void; @@ -250,6 +252,7 @@ export const useSettingsStore = create()( maxAutomaticRetries: 3, showNotifications: true, playCompletionSound: false, + autoAddClipboardLinks: false, appFontSize: 'standard', listRowDensity: 'standard', showDockBadge: true, @@ -319,6 +322,7 @@ export const useSettingsStore = create()( }), setShowNotifications: (showNotifications) => set({ showNotifications }), setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }), + setAutoAddClipboardLinks: (autoAddClipboardLinks) => set({ autoAddClipboardLinks }), setAppFontSize: (appFontSize) => set({ appFontSize }), setListRowDensity: (listRowDensity) => set({ listRowDensity }), setShowDockBadge: (showDockBadge) => { @@ -477,6 +481,7 @@ export const useSettingsStore = create()( maxAutomaticRetries: state.maxAutomaticRetries, showNotifications: state.showNotifications, playCompletionSound: state.playCompletionSound, + autoAddClipboardLinks: state.autoAddClipboardLinks, appFontSize: state.appFontSize, listRowDensity: state.listRowDensity, showDockBadge: state.showDockBadge, @@ -525,6 +530,10 @@ export const useSettingsStore = create()( : currentState.activeSettingsTab, showNotifications: persistedBoolean(persisted.showNotifications, currentState.showNotifications), playCompletionSound: persistedBoolean(persisted.playCompletionSound, currentState.playCompletionSound), + autoAddClipboardLinks: persistedBoolean( + persisted.autoAddClipboardLinks, + currentState.autoAddClipboardLinks + ), showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge), showMenuBarIcon: persistedBoolean(persisted.showMenuBarIcon, currentState.showMenuBarIcon), askWhereToSaveEachFile: persistedBoolean( diff --git a/src/utils/downloadProgress.test.ts b/src/utils/downloadProgress.test.ts new file mode 100644 index 0000000..75aa308 --- /dev/null +++ b/src/utils/downloadProgress.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { formatDownloadBytes, resolveDownloadSizeDisplay } from './downloadProgress'; + +describe('download progress size display', () => { + it('formats byte counts using the binary units used by the download engines', () => { + expect(formatDownloadBytes(0)).toBe('0 B'); + expect(formatDownloadBytes(1.2 * 1024 ** 3)).toBe('1.20 GB'); + }); + + it('keeps estimated totals distinguishable from exact totals', () => { + expect(resolveDownloadSizeDisplay({ + downloadedBytes: 1.2 * 1024 ** 3, + totalBytes: 2.4 * 1024 ** 3, + totalIsEstimate: true, + fallbackSize: 'Unknown' + })).toEqual({ + downloaded: '1.20 GB', + total: '2.40 GB', + totalIsEstimate: true, + fallback: 'Unknown' + }); + }); +}); diff --git a/src/utils/downloadProgress.ts b/src/utils/downloadProgress.ts new file mode 100644 index 0000000..e2a473c --- /dev/null +++ b/src/utils/downloadProgress.ts @@ -0,0 +1,61 @@ +export interface DownloadSizeDisplay { + downloaded: string | null; + total: string | null; + totalIsEstimate: boolean; + fallback: string; +} + +const isUsableByteCount = (value: number | null | undefined): value is number => + typeof value === 'number' && Number.isFinite(value) && value >= 0; + +export const formatDownloadBytes = (bytes: number): string => { + if (bytes < 1024) return `${Math.round(bytes)} B`; + + const units = ['KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unitIndex = -1; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + + const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2; + return `${value.toFixed(precision)} ${units[unitIndex]}`; +}; + +export const resolveDownloadSizeDisplay = ({ + downloadedBytes, + totalBytes, + totalIsEstimate = false, + fallbackSize +}: { + downloadedBytes?: number | null; + totalBytes?: number | null; + totalIsEstimate?: boolean; + fallbackSize?: string | null; +}): DownloadSizeDisplay => ({ + downloaded: isUsableByteCount(downloadedBytes) ? formatDownloadBytes(downloadedBytes) : null, + total: isUsableByteCount(totalBytes) && totalBytes > 0 ? formatDownloadBytes(totalBytes) : null, + totalIsEstimate: Boolean(totalIsEstimate && isUsableByteCount(totalBytes) && totalBytes > 0), + fallback: fallbackSize && fallbackSize !== '-' ? fallbackSize : 'Unknown' +}); + +export const downloadProgressColorClass = (status: string): string => { + switch (status) { + case 'completed': + return 'download-status-completed'; + case 'paused': + return 'download-status-paused'; + case 'failed': + return 'download-status-failed'; + case 'processing': + return 'download-status-processing'; + case 'queued': + case 'staged': + return 'download-status-queued'; + case 'retrying': + return 'download-status-retrying'; + default: + return 'download-status-downloading'; + } +}; diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts new file mode 100644 index 0000000..1f883a0 --- /dev/null +++ b/src/utils/downloads.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import type { DownloadItem } from '../bindings/DownloadItem'; +import { redactDownloadForPersistence } from './downloads'; + +const item = (status: DownloadItem['status']): DownloadItem => ({ + id: 'download-1', + url: 'https://example.com/file.bin', + fileName: 'file.bin', + status, + category: 'Other', + dateAdded: '2026-07-15T00:00:00.000Z', + downloadedBytes: 1024, + totalBytes: 4096, + totalIsEstimate: false +}); + +describe('download persistence progress snapshots', () => { + it('does not write active byte counters on every progress event', () => { + const persisted = redactDownloadForPersistence(item('downloading')); + + expect(persisted.downloadedBytes).toBeUndefined(); + expect(persisted.totalBytes).toBeUndefined(); + expect(persisted.totalIsEstimate).toBeUndefined(); + }); + + it('keeps byte counters for paused snapshots', () => { + const persisted = redactDownloadForPersistence(item('paused')); + + expect(persisted.downloadedBytes).toBe(1024); + expect(persisted.totalBytes).toBe(4096); + expect(persisted.totalIsEstimate).toBe(false); + }); +}); diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index 50565e5..214ec8f 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -119,11 +119,22 @@ export const isMediaUrl = (rawUrl: string): boolean => { * persistence boundary so the user-data database contains no plaintext credentials. */ const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const; +const VOLATILE_PROGRESS_STATUSES = new Set([ + 'ready', + 'staged', + 'queued', + 'downloading', + 'processing', + 'retrying' +]); /** * Returns a shallow copy of `item` with secret fields removed. Volatile * progress fields (`fraction`, `speed`, `eta`) are also dropped as in the - * existing persistence path. + * existing persistence path. Numeric byte totals remain for paused, failed, + * and completed rows so those snapshots keep their accurate Size-column + * display after restart; active-transfer counters stay in memory to avoid a + * database write for every progress tick. * * Note: standard persistence intentionally retains `url` because it is the * download source. The backend applies a stricter portable-mode policy: URL @@ -135,6 +146,11 @@ export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem = delete copy.fraction; delete copy.speed; delete copy.eta; + if (VOLATILE_PROGRESS_STATUSES.has(item.status)) { + delete copy.downloadedBytes; + delete copy.totalBytes; + delete copy.totalIsEstimate; + } for (const field of DOWNLOAD_SECRET_FIELDS) { delete copy[field]; }