mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-28 11:37:17 +00:00
feat(downloads): add live limits clipboard capture and byte progress
This commit is contained in:
@@ -80,6 +80,12 @@ pub struct DownloadItem {
|
|||||||
pub eta: Option<String>,
|
pub eta: Option<String>,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub size: Option<String>,
|
pub size: Option<String>,
|
||||||
|
#[ts(optional)]
|
||||||
|
pub downloaded_bytes: Option<f64>,
|
||||||
|
#[ts(optional)]
|
||||||
|
pub total_bytes: Option<f64>,
|
||||||
|
#[ts(optional)]
|
||||||
|
pub total_is_estimate: Option<bool>,
|
||||||
pub category: DownloadCategory,
|
pub category: DownloadCategory,
|
||||||
pub date_added: String,
|
pub date_added: String,
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
@@ -274,6 +280,8 @@ pub struct PersistedSettings {
|
|||||||
pub max_automatic_retries: i32,
|
pub max_automatic_retries: i32,
|
||||||
pub show_notifications: bool,
|
pub show_notifications: bool,
|
||||||
pub play_completion_sound: bool,
|
pub play_completion_sound: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_add_clipboard_links: bool,
|
||||||
pub app_font_size: AppFontSize,
|
pub app_font_size: AppFontSize,
|
||||||
pub list_row_density: ListRowDensity,
|
pub list_row_density: ListRowDensity,
|
||||||
pub show_dock_badge: bool,
|
pub show_dock_badge: bool,
|
||||||
|
|||||||
+174
-13
@@ -827,6 +827,8 @@ struct MediaProgress {
|
|||||||
eta: String,
|
eta: String,
|
||||||
size: Option<String>,
|
size: Option<String>,
|
||||||
downloaded_bytes: Option<f64>,
|
downloaded_bytes: Option<f64>,
|
||||||
|
total_bytes: Option<f64>,
|
||||||
|
total_is_estimate: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn progress_json_number(progress: &serde_json::Value, key: &str) -> Option<f64> {
|
fn progress_json_number(progress: &serde_json::Value, key: &str) -> Option<f64> {
|
||||||
@@ -928,13 +930,17 @@ fn parse_media_progress_line(line: &str) -> Option<MediaProgress> {
|
|||||||
let size = progress_json_string(&progress, "_total_bytes_str")
|
let size = progress_json_string(&progress, "_total_bytes_str")
|
||||||
.or_else(|| progress_json_string(&progress, "_total_bytes_estimate_str"))
|
.or_else(|| progress_json_string(&progress, "_total_bytes_estimate_str"))
|
||||||
.or_else(|| (total > 0.0).then(|| crate::download::format_size(total)));
|
.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 {
|
return Some(MediaProgress {
|
||||||
fraction: fraction.clamp(0.0, 1.0),
|
fraction: fraction.clamp(0.0, 1.0),
|
||||||
speed,
|
speed,
|
||||||
eta,
|
eta,
|
||||||
size,
|
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<MediaProgress> {
|
|||||||
.map(|value| value.as_str().to_string())
|
.map(|value| value.as_str().to_string())
|
||||||
.unwrap_or_else(|| "-".to_string());
|
.unwrap_or_else(|| "-".to_string());
|
||||||
let size = captures.get(2).map(|value| value.as_str().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 {
|
return Some(MediaProgress {
|
||||||
fraction: fraction.clamp(0.0, 1.0),
|
fraction: fraction.clamp(0.0, 1.0),
|
||||||
speed,
|
speed,
|
||||||
eta,
|
eta,
|
||||||
size,
|
size,
|
||||||
downloaded_bytes: None,
|
downloaded_bytes,
|
||||||
|
total_bytes,
|
||||||
|
total_is_estimate: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -976,16 +990,24 @@ fn parse_media_progress_line(line: &str) -> Option<MediaProgress> {
|
|||||||
let fraction = captures.get(1)?.as_str().parse::<f64>().ok()? / 100.0;
|
let fraction = captures.get(1)?.as_str().parse::<f64>().ok()? / 100.0;
|
||||||
let speed_re = YTDLP_SPD_RE.get_or_init(|| Regex::new(r"\bat\s+([^\s]+)").unwrap());
|
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 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 parsed_size_str = None;
|
||||||
let mut downloaded_bytes = 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(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());
|
parsed_size_str = Some(size_str.as_str().to_string());
|
||||||
if let Some(total_bytes) = parse_human_size(size_str.as_str()) {
|
if let Some(parsed_total_bytes) = parse_human_size(size_str.as_str()) {
|
||||||
downloaded_bytes = Some(total_bytes * fraction);
|
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 {
|
Some(MediaProgress {
|
||||||
@@ -1002,6 +1024,8 @@ fn parse_media_progress_line(line: &str) -> Option<MediaProgress> {
|
|||||||
.unwrap_or_else(|| "-".to_string()),
|
.unwrap_or_else(|| "-".to_string()),
|
||||||
size: parsed_size_str,
|
size: parsed_size_str,
|
||||||
downloaded_bytes,
|
downloaded_bytes,
|
||||||
|
total_bytes,
|
||||||
|
total_is_estimate,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1110,6 +1134,12 @@ struct MediaProgressEmitterState {
|
|||||||
last_fraction: f64,
|
last_fraction: f64,
|
||||||
speed_sampler: MediaSpeedSampler,
|
speed_sampler: MediaSpeedSampler,
|
||||||
last_progress_at: Instant,
|
last_progress_at: Instant,
|
||||||
|
completed_tracks_downloaded_bytes: Option<f64>,
|
||||||
|
completed_tracks_total_bytes: Option<f64>,
|
||||||
|
completed_tracks_total_is_estimate: bool,
|
||||||
|
current_track_downloaded_bytes: Option<f64>,
|
||||||
|
current_track_total_bytes: Option<f64>,
|
||||||
|
current_track_total_is_estimate: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MediaProgressEmitterState {
|
impl MediaProgressEmitterState {
|
||||||
@@ -1121,10 +1151,64 @@ impl MediaProgressEmitterState {
|
|||||||
last_progress_at: Instant::now()
|
last_progress_at: Instant::now()
|
||||||
.checked_sub(MEDIA_PROGRESS_EMIT_INTERVAL)
|
.checked_sub(MEDIA_PROGRESS_EMIT_INTERVAL)
|
||||||
.unwrap_or_else(Instant::now),
|
.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(
|
fn emit_media_progress(
|
||||||
app_handle: &tauri::AppHandle,
|
app_handle: &tauri::AppHandle,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -1139,9 +1223,11 @@ fn emit_media_progress(
|
|||||||
&mut state.last_fraction,
|
&mut state.last_fraction,
|
||||||
progress.fraction,
|
progress.fraction,
|
||||||
);
|
);
|
||||||
if state.current_track != previous_track {
|
let track_changed = state.current_track != previous_track;
|
||||||
|
if track_changed {
|
||||||
state.speed_sampler.reset();
|
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 (speed, eta) = media_progress_speed(&progress, Instant::now(), &mut state.speed_sampler);
|
||||||
|
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
@@ -1155,6 +1241,9 @@ fn emit_media_progress(
|
|||||||
eta,
|
eta,
|
||||||
size: progress.size,
|
size: progress.size,
|
||||||
size_is_final: false,
|
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;
|
state.last_progress_at = now;
|
||||||
@@ -2169,6 +2258,12 @@ pub struct DownloadProgressEvent {
|
|||||||
eta: String,
|
eta: String,
|
||||||
size: Option<String>,
|
size: Option<String>,
|
||||||
size_is_final: bool,
|
size_is_final: bool,
|
||||||
|
#[ts(optional)]
|
||||||
|
downloaded_bytes: Option<f64>,
|
||||||
|
#[ts(optional)]
|
||||||
|
total_bytes: Option<f64>,
|
||||||
|
#[ts(optional)]
|
||||||
|
total_is_estimate: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, TS)]
|
#[derive(Debug, Clone, Serialize, TS)]
|
||||||
@@ -3271,6 +3366,9 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
eta: "-".to_string(),
|
eta: "-".to_string(),
|
||||||
size: None,
|
size: None,
|
||||||
size_is_final: false,
|
size_is_final: false,
|
||||||
|
downloaded_bytes: None,
|
||||||
|
total_bytes: None,
|
||||||
|
total_is_estimate: Some(false),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let lower = line.to_lowercase();
|
let lower = line.to_lowercase();
|
||||||
@@ -3339,6 +3437,9 @@ pub(crate) async fn start_media_download_internal(
|
|||||||
eta: "-".to_string(),
|
eta: "-".to_string(),
|
||||||
size: Some(crate::download::format_size(metadata.len() as f64)),
|
size: Some(crate::download::format_size(metadata.len() as f64)),
|
||||||
size_is_final: true,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
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,
|
build_media_format_options,
|
||||||
collect_download_uris, drain_media_output_lines, filename_from_content_disposition,
|
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,
|
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,
|
retry_metadata_with_cookies, should_retry_metadata_with_cookies,
|
||||||
should_send_metadata_credentials, collect_log_files, FirelinkDeepLink,
|
should_send_metadata_credentials, collect_log_files, FirelinkDeepLink,
|
||||||
percent_decode_metadata_value, MediaProgress,
|
percent_decode_metadata_value, MediaProgress,
|
||||||
MediaSpeedSampler, MEDIA_PROGRESS_PREFIX,
|
MediaProgressEmitterState, MediaSpeedSampler, MEDIA_PROGRESS_PREFIX,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -6314,6 +6416,8 @@ mod tests {
|
|||||||
eta: "00:05".to_string(),
|
eta: "00:05".to_string(),
|
||||||
size: Some("10.00MiB".to_string()),
|
size: Some("10.00MiB".to_string()),
|
||||||
downloaded_bytes: Some(5242880.0),
|
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);
|
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]
|
#[test]
|
||||||
fn derives_main_window_speed_from_downloaded_byte_delta() {
|
fn derives_main_window_speed_from_downloaded_byte_delta() {
|
||||||
let first = MediaProgress {
|
let first = MediaProgress {
|
||||||
@@ -6408,6 +6556,8 @@ mod tests {
|
|||||||
eta: "-".to_string(),
|
eta: "-".to_string(),
|
||||||
size: None,
|
size: None,
|
||||||
downloaded_bytes: Some(1_000_000.0),
|
downloaded_bytes: Some(1_000_000.0),
|
||||||
|
total_bytes: None,
|
||||||
|
total_is_estimate: false,
|
||||||
};
|
};
|
||||||
let second = MediaProgress {
|
let second = MediaProgress {
|
||||||
fraction: 0.5,
|
fraction: 0.5,
|
||||||
@@ -6415,6 +6565,8 @@ mod tests {
|
|||||||
eta: "-".to_string(),
|
eta: "-".to_string(),
|
||||||
size: None,
|
size: None,
|
||||||
downloaded_bytes: Some(3_097_152.0),
|
downloaded_bytes: Some(3_097_152.0),
|
||||||
|
total_bytes: None,
|
||||||
|
total_is_estimate: false,
|
||||||
};
|
};
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut sampler = MediaSpeedSampler::default();
|
let mut sampler = MediaSpeedSampler::default();
|
||||||
@@ -6439,6 +6591,8 @@ mod tests {
|
|||||||
eta: "-".to_string(),
|
eta: "-".to_string(),
|
||||||
size: None,
|
size: None,
|
||||||
downloaded_bytes: Some(1_000_000.0),
|
downloaded_bytes: Some(1_000_000.0),
|
||||||
|
total_bytes: None,
|
||||||
|
total_is_estimate: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -6471,14 +6625,16 @@ mod tests {
|
|||||||
speed: "910KiB/s".to_string(),
|
speed: "910KiB/s".to_string(),
|
||||||
eta: "25s".to_string(),
|
eta: "25s".to_string(),
|
||||||
size: Some("34MiB".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]
|
#[test]
|
||||||
fn retains_legacy_ytdlp_progress_fallback() {
|
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!(
|
assert_eq!(
|
||||||
parse_media_progress_line(line),
|
parse_media_progress_line(line),
|
||||||
@@ -6488,6 +6644,8 @@ mod tests {
|
|||||||
eta: "00:03".to_string(),
|
eta: "00:03".to_string(),
|
||||||
size: Some("10.00MiB".to_string()),
|
size: Some("10.00MiB".to_string()),
|
||||||
downloaded_bytes: Some(4456448.0),
|
downloaded_bytes: Some(4456448.0),
|
||||||
|
total_bytes: Some(10485760.0),
|
||||||
|
total_is_estimate: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7043,9 +7201,12 @@ pub fn run() {
|
|||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
fraction,
|
fraction,
|
||||||
speed,
|
speed,
|
||||||
eta,
|
eta,
|
||||||
size,
|
size,
|
||||||
size_is_final: false,
|
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 {
|
if should_refresh {
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ fn default_settings() -> PersistedSettings {
|
|||||||
max_automatic_retries: 3,
|
max_automatic_retries: 3,
|
||||||
show_notifications: true,
|
show_notifications: true,
|
||||||
play_completion_sound: false,
|
play_completion_sound: false,
|
||||||
|
auto_add_clipboard_links: false,
|
||||||
app_font_size: AppFontSize::Standard,
|
app_font_size: AppFontSize::Standard,
|
||||||
list_row_density: ListRowDensity::Standard,
|
list_row_density: ListRowDensity::Standard,
|
||||||
show_dock_badge: true,
|
show_dock_badge: true,
|
||||||
@@ -696,8 +697,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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().play_completion_sound);
|
||||||
|
assert!(!default_settings().auto_add_clipboard_links);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+77
@@ -8,6 +8,7 @@ import SettingsView from "./components/SettingsView";
|
|||||||
import { PropertiesModal } from "./components/PropertiesModal";
|
import { PropertiesModal } from "./components/PropertiesModal";
|
||||||
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
|
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
|
||||||
import { extractValidDownloadUrls } from './utils/url';
|
import { extractValidDownloadUrls } from './utils/url';
|
||||||
|
import { readClipboardDownloadUrls } from './utils/clipboard';
|
||||||
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
||||||
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
|
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
|
||||||
import { initDownloadListener } from './store/downloadStore';
|
import { initDownloadListener } from './store/downloadStore';
|
||||||
@@ -110,6 +111,7 @@ function App() {
|
|||||||
const appFontSize = useSettingsStore(state => state.appFontSize);
|
const appFontSize = useSettingsStore(state => state.appFontSize);
|
||||||
const listRowDensity = useSettingsStore(state => state.listRowDensity);
|
const listRowDensity = useSettingsStore(state => state.listRowDensity);
|
||||||
const autoCheckUpdates = useSettingsStore(state => state.autoCheckUpdates);
|
const autoCheckUpdates = useSettingsStore(state => state.autoCheckUpdates);
|
||||||
|
const autoAddClipboardLinks = useSettingsStore(state => state.autoAddClipboardLinks);
|
||||||
const showNotifications = useSettingsStore(state => state.showNotifications);
|
const showNotifications = useSettingsStore(state => state.showNotifications);
|
||||||
const showDockBadge = useSettingsStore(state => state.showDockBadge);
|
const showDockBadge = useSettingsStore(state => state.showDockBadge);
|
||||||
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
|
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
|
||||||
@@ -695,6 +697,81 @@ function App() {
|
|||||||
return () => window.removeEventListener('paste', handlePaste);
|
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(() => {
|
useEffect(() => {
|
||||||
const root = window.document.documentElement;
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
import type { DownloadCategory } from "./DownloadCategory";
|
import type { DownloadCategory } from "./DownloadCategory";
|
||||||
import type { DownloadStatus } from "./DownloadStatus";
|
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, };
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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, };
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
|
|||||||
import type { SiteLogin } from "./SiteLogin";
|
import type { SiteLogin } from "./SiteLogin";
|
||||||
import type { Theme } from "./Theme";
|
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<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, 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<SiteLogin>, 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<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, 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<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-rea
|
|||||||
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
|
||||||
import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions';
|
import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions';
|
||||||
import { isActiveDownloadStatus } from '../utils/downloads';
|
import { isActiveDownloadStatus } from '../utils/downloads';
|
||||||
|
import {
|
||||||
|
downloadProgressColorClass,
|
||||||
|
resolveDownloadSizeDisplay
|
||||||
|
} from '../utils/downloadProgress';
|
||||||
|
|
||||||
interface DownloadItemProps {
|
interface DownloadItemProps {
|
||||||
downloadId: string;
|
downloadId: string;
|
||||||
@@ -64,6 +68,13 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
: download.status === 'processing'
|
: download.status === 'processing'
|
||||||
? 'Muxing...'
|
? '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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -85,8 +96,24 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="download-cell-truncate">
|
<div className="download-cell-truncate">
|
||||||
<span className="tabular-nums" title={download.size && download.size !== '-' ? download.size : 'Unknown'}>
|
<span
|
||||||
{download.size && download.size !== '-' ? download.size : 'Unknown'}
|
className="tabular-nums"
|
||||||
|
title={hasDownloadedAmount
|
||||||
|
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total}`
|
||||||
|
: sizeDisplay.fallback}
|
||||||
|
aria-label={hasDownloadedAmount
|
||||||
|
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total}`
|
||||||
|
: sizeDisplay.fallback}
|
||||||
|
>
|
||||||
|
{hasDownloadedAmount ? (
|
||||||
|
<>
|
||||||
|
<span className={downloadProgressColorClass(download.status)}>{sizeDisplay.downloaded}</span>
|
||||||
|
<span className="text-text-muted"> / </span>
|
||||||
|
<span>
|
||||||
|
{sizeDisplay.totalIsEstimate ? '~' : ''}{sizeDisplay.total}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : sizeDisplay.fallback}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
isIdentityLocked as getIdentityLocked,
|
isIdentityLocked as getIdentityLocked,
|
||||||
isTransferLocked as getTransferLocked
|
isTransferLocked as getTransferLocked
|
||||||
} from '../utils/downloadActions';
|
} from '../utils/downloadActions';
|
||||||
|
import {
|
||||||
|
downloadProgressColorClass,
|
||||||
|
resolveDownloadSizeDisplay
|
||||||
|
} from '../utils/downloadProgress';
|
||||||
|
|
||||||
type LoginMode = 'matching' | 'custom' | 'none';
|
type LoginMode = 'matching' | 'custom' | 'none';
|
||||||
|
|
||||||
@@ -185,6 +189,13 @@ export const PropertiesModal = () => {
|
|||||||
const displayedEta = item.status === 'completed'
|
const displayedEta = item.status === 'completed'
|
||||||
? '-'
|
? '-'
|
||||||
: liveProgress?.eta ?? item.eta ?? '-';
|
: 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 statusColor = 'text-text-secondary';
|
||||||
let StatusIcon = Info;
|
let StatusIcon = Info;
|
||||||
@@ -221,7 +232,25 @@ export const PropertiesModal = () => {
|
|||||||
|
|
||||||
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
|
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
|
||||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Progress</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
|
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Progress</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
|
||||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Size</span><span className="text-text-secondary truncate">{item.size || '-'}</span></div>
|
<div className="flex gap-1.5 min-w-0">
|
||||||
|
<span className="text-text-muted font-medium w-[40px] shrink-0">Size</span>
|
||||||
|
<span
|
||||||
|
className="truncate"
|
||||||
|
title={hasDownloadedAmount
|
||||||
|
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total}`
|
||||||
|
: sizeDisplay.fallback}
|
||||||
|
>
|
||||||
|
{hasDownloadedAmount ? (
|
||||||
|
<>
|
||||||
|
<span className={downloadProgressColorClass(item.status)}>{sizeDisplay.downloaded}</span>
|
||||||
|
<span className="text-text-muted"> / </span>
|
||||||
|
<span className="text-text-secondary">
|
||||||
|
{sizeDisplay.totalIsEstimate ? '~' : ''}{sizeDisplay.total}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : sizeDisplay.fallback}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Speed</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
|
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Speed</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
|
||||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">ETA</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
|
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">ETA</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
|
||||||
|
|
||||||
|
|||||||
@@ -700,6 +700,18 @@ runEngineChecks(false);
|
|||||||
className="mac-switch"
|
className="mac-switch"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="mac-settings-row cursor-default">
|
||||||
|
<div className="settings-row-label">
|
||||||
|
<span>Add clipboard links when Firelink becomes active</span>
|
||||||
|
<small>Opens supported copied links in the Add window. Off by default.</small>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={settings.autoAddClipboardLinks}
|
||||||
|
onChange={(e) => settings.setAutoAddClipboardLinks(e.target.checked)}
|
||||||
|
className="mac-switch"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ const startDownloadListeners = async () => {
|
|||||||
if (shouldUpdateSize && current.size !== payload.size) {
|
if (shouldUpdateSize && current.size !== payload.size) {
|
||||||
updates.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) {
|
if (Object.keys(updates).length > 0) {
|
||||||
mainStore.updateDownload(payload.id, updates);
|
mainStore.updateDownload(payload.id, updates);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
const defaultSettings = useSettingsStore.getState();
|
||||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
@@ -534,6 +534,37 @@ describe('useDownloadStore', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
item: expect.objectContaining({
|
item: expect.objectContaining({
|
||||||
id: 'inherits-global',
|
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'
|
speed_limit: '2M'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -134,11 +134,21 @@ const stripCookieHeaders = (value: string | null | undefined): string =>
|
|||||||
.join('\n')
|
.join('\n')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const speedLimitForDispatch = (itemSpeedLimit: string | undefined, globalSpeedLimit: string): string | null => {
|
const explicitSpeedLimitForDispatch = (itemSpeedLimit: string | undefined): string | null => {
|
||||||
const explicitLimit = itemSpeedLimit?.trim();
|
const explicitLimit = itemSpeedLimit?.trim();
|
||||||
if (explicitLimit) {
|
if (explicitLimit) {
|
||||||
return normalizeSpeedLimitForBackend(explicitLimit) || (explicitLimit === '0' ? '0' : null);
|
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);
|
return normalizeSpeedLimitForBackend(globalSpeedLimit);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -186,7 +196,7 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
|||||||
destination,
|
destination,
|
||||||
filename: item.fileName,
|
filename: item.fileName,
|
||||||
connections: item.connections || settings.perServerConnections || null,
|
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),
|
username: item.username || (login ? login.username : null),
|
||||||
password: item.password || keychainPassword,
|
password: item.password || keychainPassword,
|
||||||
headers: item.headers || null,
|
headers: item.headers || null,
|
||||||
@@ -842,6 +852,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
fraction: 0,
|
fraction: 0,
|
||||||
speed: '-',
|
speed: '-',
|
||||||
eta: '-',
|
eta: '-',
|
||||||
|
downloadedBytes: undefined,
|
||||||
|
totalBytes: undefined,
|
||||||
|
totalIsEstimate: undefined,
|
||||||
hasBeenDispatched: false,
|
hasBeenDispatched: false,
|
||||||
dateAdded: new Date().toISOString()
|
dateAdded: new Date().toISOString()
|
||||||
});
|
});
|
||||||
@@ -1159,7 +1172,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
|||||||
destination: destPath,
|
destination: destPath,
|
||||||
filename: item.fileName,
|
filename: item.fileName,
|
||||||
connections: item.connections || settings.perServerConnections || null,
|
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),
|
username: item.username || (login ? login.username : null),
|
||||||
password: item.password || keychainPassword,
|
password: item.password || keychainPassword,
|
||||||
headers: item.headers || null,
|
headers: item.headers || null,
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export interface SettingsState {
|
|||||||
maxAutomaticRetries: number;
|
maxAutomaticRetries: number;
|
||||||
showNotifications: boolean;
|
showNotifications: boolean;
|
||||||
playCompletionSound: boolean;
|
playCompletionSound: boolean;
|
||||||
|
autoAddClipboardLinks: boolean;
|
||||||
appFontSize: AppFontSize;
|
appFontSize: AppFontSize;
|
||||||
listRowDensity: ListRowDensity;
|
listRowDensity: ListRowDensity;
|
||||||
showDockBadge: boolean;
|
showDockBadge: boolean;
|
||||||
@@ -186,6 +187,7 @@ export interface SettingsState {
|
|||||||
setMaxAutomaticRetries: (count: number) => void;
|
setMaxAutomaticRetries: (count: number) => void;
|
||||||
setShowNotifications: (show: boolean) => void;
|
setShowNotifications: (show: boolean) => void;
|
||||||
setPlayCompletionSound: (play: boolean) => void;
|
setPlayCompletionSound: (play: boolean) => void;
|
||||||
|
setAutoAddClipboardLinks: (enabled: boolean) => void;
|
||||||
setAppFontSize: (size: AppFontSize) => void;
|
setAppFontSize: (size: AppFontSize) => void;
|
||||||
setListRowDensity: (density: ListRowDensity) => void;
|
setListRowDensity: (density: ListRowDensity) => void;
|
||||||
setShowDockBadge: (show: boolean) => void;
|
setShowDockBadge: (show: boolean) => void;
|
||||||
@@ -250,6 +252,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
maxAutomaticRetries: 3,
|
maxAutomaticRetries: 3,
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
playCompletionSound: false,
|
playCompletionSound: false,
|
||||||
|
autoAddClipboardLinks: false,
|
||||||
appFontSize: 'standard',
|
appFontSize: 'standard',
|
||||||
listRowDensity: 'standard',
|
listRowDensity: 'standard',
|
||||||
showDockBadge: true,
|
showDockBadge: true,
|
||||||
@@ -319,6 +322,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
}),
|
}),
|
||||||
setShowNotifications: (showNotifications) => set({ showNotifications }),
|
setShowNotifications: (showNotifications) => set({ showNotifications }),
|
||||||
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
|
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
|
||||||
|
setAutoAddClipboardLinks: (autoAddClipboardLinks) => set({ autoAddClipboardLinks }),
|
||||||
setAppFontSize: (appFontSize) => set({ appFontSize }),
|
setAppFontSize: (appFontSize) => set({ appFontSize }),
|
||||||
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
|
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
|
||||||
setShowDockBadge: (showDockBadge) => {
|
setShowDockBadge: (showDockBadge) => {
|
||||||
@@ -477,6 +481,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
maxAutomaticRetries: state.maxAutomaticRetries,
|
maxAutomaticRetries: state.maxAutomaticRetries,
|
||||||
showNotifications: state.showNotifications,
|
showNotifications: state.showNotifications,
|
||||||
playCompletionSound: state.playCompletionSound,
|
playCompletionSound: state.playCompletionSound,
|
||||||
|
autoAddClipboardLinks: state.autoAddClipboardLinks,
|
||||||
appFontSize: state.appFontSize,
|
appFontSize: state.appFontSize,
|
||||||
listRowDensity: state.listRowDensity,
|
listRowDensity: state.listRowDensity,
|
||||||
showDockBadge: state.showDockBadge,
|
showDockBadge: state.showDockBadge,
|
||||||
@@ -525,6 +530,10 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
: currentState.activeSettingsTab,
|
: currentState.activeSettingsTab,
|
||||||
showNotifications: persistedBoolean(persisted.showNotifications, currentState.showNotifications),
|
showNotifications: persistedBoolean(persisted.showNotifications, currentState.showNotifications),
|
||||||
playCompletionSound: persistedBoolean(persisted.playCompletionSound, currentState.playCompletionSound),
|
playCompletionSound: persistedBoolean(persisted.playCompletionSound, currentState.playCompletionSound),
|
||||||
|
autoAddClipboardLinks: persistedBoolean(
|
||||||
|
persisted.autoAddClipboardLinks,
|
||||||
|
currentState.autoAddClipboardLinks
|
||||||
|
),
|
||||||
showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge),
|
showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge),
|
||||||
showMenuBarIcon: persistedBoolean(persisted.showMenuBarIcon, currentState.showMenuBarIcon),
|
showMenuBarIcon: persistedBoolean(persisted.showMenuBarIcon, currentState.showMenuBarIcon),
|
||||||
askWhereToSaveEachFile: persistedBoolean(
|
askWhereToSaveEachFile: persistedBoolean(
|
||||||
|
|||||||
@@ -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'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-1
@@ -119,11 +119,22 @@ export const isMediaUrl = (rawUrl: string): boolean => {
|
|||||||
* persistence boundary so the user-data database contains no plaintext credentials.
|
* persistence boundary so the user-data database contains no plaintext credentials.
|
||||||
*/
|
*/
|
||||||
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
|
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
|
* Returns a shallow copy of `item` with secret fields removed. Volatile
|
||||||
* progress fields (`fraction`, `speed`, `eta`) are also dropped as in the
|
* 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
|
* Note: standard persistence intentionally retains `url` because it is the
|
||||||
* download source. The backend applies a stricter portable-mode policy: URL
|
* 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.fraction;
|
||||||
delete copy.speed;
|
delete copy.speed;
|
||||||
delete copy.eta;
|
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) {
|
for (const field of DOWNLOAD_SECRET_FIELDS) {
|
||||||
delete copy[field];
|
delete copy[field];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user