mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +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>,
|
||||
#[ts(optional)]
|
||||
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 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,
|
||||
|
||||
+174
-13
@@ -827,6 +827,8 @@ struct MediaProgress {
|
||||
eta: String,
|
||||
size: Option<String>,
|
||||
downloaded_bytes: Option<f64>,
|
||||
total_bytes: Option<f64>,
|
||||
total_is_estimate: bool,
|
||||
}
|
||||
|
||||
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")
|
||||
.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<MediaProgress> {
|
||||
.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<MediaProgress> {
|
||||
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 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<MediaProgress> {
|
||||
.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<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 {
|
||||
@@ -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<String>,
|
||||
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)]
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
+77
@@ -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;
|
||||
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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<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 { 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<DownloadItemProps>(({
|
||||
: 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 (
|
||||
<div
|
||||
@@ -85,8 +96,24 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
</div>
|
||||
|
||||
<div className="download-cell-truncate">
|
||||
<span className="tabular-nums" title={download.size && download.size !== '-' ? download.size : 'Unknown'}>
|
||||
{download.size && download.size !== '-' ? download.size : 'Unknown'}
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
<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-[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-[30px] shrink-0">ETA</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
|
||||
|
||||
|
||||
@@ -700,6 +700,18 @@ runEngineChecks(false);
|
||||
className="mac-switch"
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<DownloadState>((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<DownloadState>((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,
|
||||
|
||||
@@ -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<SettingsState>()(
|
||||
maxAutomaticRetries: 3,
|
||||
showNotifications: true,
|
||||
playCompletionSound: false,
|
||||
autoAddClipboardLinks: false,
|
||||
appFontSize: 'standard',
|
||||
listRowDensity: 'standard',
|
||||
showDockBadge: true,
|
||||
@@ -319,6 +322,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
}),
|
||||
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<SettingsState>()(
|
||||
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<SettingsState>()(
|
||||
: 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(
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user