mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
This commit is contained in:
+441
-74
@@ -4855,10 +4855,10 @@ async fn set_global_speed_limit(
|
||||
state: tauri::State<'_, AppState>,
|
||||
limit: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let limit_str = limit
|
||||
let normalized_limit = limit
|
||||
.as_deref()
|
||||
.and_then(normalize_speed_limit_for_aria2)
|
||||
.unwrap_or_else(|| "0".to_string());
|
||||
.and_then(normalize_speed_limit_for_aria2);
|
||||
let limit_str = normalized_limit.clone().unwrap_or_else(|| "0".to_string());
|
||||
rpc_call(
|
||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
&state.aria2_secret,
|
||||
@@ -4866,7 +4866,11 @@ async fn set_global_speed_limit(
|
||||
serde_json::json!([{"max-overall-download-limit": limit_str}]),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map(|_| {
|
||||
state
|
||||
.queue_manager
|
||||
.set_aria2_global_speed_limit(normalized_limit);
|
||||
})
|
||||
.map_err(|e| {
|
||||
eprintln!("Failed to set global speed limit: {}", e);
|
||||
e
|
||||
@@ -5700,10 +5704,267 @@ mod tests {
|
||||
should_send_metadata_credentials, collect_log_files, FirelinkDeepLink,
|
||||
percent_decode_metadata_value, MediaProgress,
|
||||
MediaProgressEmitterState, MediaSpeedSampler, MEDIA_PROGRESS_PREFIX,
|
||||
observe_aria2_connections, Aria2ConnectionObservation, Aria2RecoveryReason,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn slow_nonzero_aria2_throughput_recovers_after_a_sustained_degradation() {
|
||||
let start = Instant::now();
|
||||
let mut observation = Aria2ConnectionObservation::default();
|
||||
|
||||
assert_eq!(
|
||||
observe_aria2_connections(
|
||||
&mut observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
10 * 1024 * 1024,
|
||||
10.0 * 1024.0 * 1024.0,
|
||||
16,
|
||||
16,
|
||||
false,
|
||||
start,
|
||||
),
|
||||
None
|
||||
);
|
||||
observe_aria2_connections(
|
||||
&mut observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
11 * 1024 * 1024,
|
||||
10.0 * 1024.0 * 1024.0,
|
||||
16,
|
||||
16,
|
||||
false,
|
||||
start + Duration::from_secs(1),
|
||||
);
|
||||
observe_aria2_connections(
|
||||
&mut observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
12 * 1024 * 1024,
|
||||
10.0 * 1024.0 * 1024.0,
|
||||
16,
|
||||
16,
|
||||
false,
|
||||
start + Duration::from_secs(2),
|
||||
);
|
||||
assert_eq!(
|
||||
observe_aria2_connections(
|
||||
&mut observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
11 * 1024 * 1024,
|
||||
1024.0 * 1024.0,
|
||||
16,
|
||||
16,
|
||||
false,
|
||||
start + Duration::from_secs(31),
|
||||
),
|
||||
None,
|
||||
"the first slow sample starts the degradation timer"
|
||||
);
|
||||
assert_eq!(
|
||||
observe_aria2_connections(
|
||||
&mut observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
12 * 1024 * 1024,
|
||||
1024.0 * 1024.0,
|
||||
16,
|
||||
16,
|
||||
false,
|
||||
start + Duration::from_secs(62),
|
||||
),
|
||||
Some(Aria2RecoveryReason::SlowThroughput)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aria2_recovery_uses_a_cooldown_instead_of_a_one_shot_latch() {
|
||||
let start = Instant::now();
|
||||
let mut observation = Aria2ConnectionObservation::default();
|
||||
let sample = |observation: &mut Aria2ConnectionObservation, now: Instant| {
|
||||
observe_aria2_connections(
|
||||
observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
12 * 1024 * 1024,
|
||||
1024.0 * 1024.0,
|
||||
16,
|
||||
16,
|
||||
false,
|
||||
now,
|
||||
)
|
||||
};
|
||||
|
||||
observe_aria2_connections(
|
||||
&mut observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
12 * 1024 * 1024,
|
||||
10.0 * 1024.0 * 1024.0,
|
||||
16,
|
||||
16,
|
||||
false,
|
||||
start,
|
||||
);
|
||||
observe_aria2_connections(
|
||||
&mut observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
13 * 1024 * 1024,
|
||||
10.0 * 1024.0 * 1024.0,
|
||||
16,
|
||||
16,
|
||||
false,
|
||||
start + Duration::from_secs(1),
|
||||
);
|
||||
observe_aria2_connections(
|
||||
&mut observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
14 * 1024 * 1024,
|
||||
10.0 * 1024.0 * 1024.0,
|
||||
16,
|
||||
16,
|
||||
false,
|
||||
start + Duration::from_secs(2),
|
||||
);
|
||||
sample(&mut observation, start + Duration::from_secs(31));
|
||||
assert_eq!(
|
||||
sample(&mut observation, start + Duration::from_secs(62)),
|
||||
Some(Aria2RecoveryReason::SlowThroughput)
|
||||
);
|
||||
assert_eq!(sample(&mut observation, start + Duration::from_secs(93)), None);
|
||||
assert_eq!(
|
||||
sample(&mut observation, start + Duration::from_secs(108)),
|
||||
Some(Aria2RecoveryReason::SlowThroughput),
|
||||
"a later degradation can recover again after the cooldown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slow_recovery_requires_a_real_multi_connection_transfer() {
|
||||
let start = Instant::now();
|
||||
let mut observation = Aria2ConnectionObservation::default();
|
||||
|
||||
for (offset, speed) in [(0, 10.0 * 1024.0 * 1024.0), (31, 1024.0 * 1024.0), (62, 1024.0 * 1024.0)] {
|
||||
assert_eq!(
|
||||
observe_aria2_connections(
|
||||
&mut observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
10 * 1024 * 1024 + offset * 1024,
|
||||
speed,
|
||||
1,
|
||||
16,
|
||||
false,
|
||||
start + Duration::from_secs(offset as u64),
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slow_recovery_ignores_intentional_speed_limits() {
|
||||
let start = Instant::now();
|
||||
let mut observation = Aria2ConnectionObservation::default();
|
||||
for offset in 0..3 {
|
||||
observe_aria2_connections(
|
||||
&mut observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
(10 + offset) * 1024 * 1024,
|
||||
10.0 * 1024.0 * 1024.0,
|
||||
16,
|
||||
16,
|
||||
true,
|
||||
start + Duration::from_secs(offset),
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
observe_aria2_connections(
|
||||
&mut observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
14 * 1024 * 1024,
|
||||
1024.0 * 1024.0,
|
||||
16,
|
||||
16,
|
||||
true,
|
||||
start + Duration::from_secs(31),
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
observe_aria2_connections(
|
||||
&mut observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
15 * 1024 * 1024,
|
||||
1024.0 * 1024.0,
|
||||
16,
|
||||
16,
|
||||
true,
|
||||
start + Duration::from_secs(62),
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_startup_spike_does_not_arm_slow_recovery() {
|
||||
let start = Instant::now();
|
||||
let mut observation = Aria2ConnectionObservation::default();
|
||||
observe_aria2_connections(
|
||||
&mut observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
10 * 1024 * 1024,
|
||||
10.0 * 1024.0 * 1024.0,
|
||||
16,
|
||||
16,
|
||||
false,
|
||||
start,
|
||||
);
|
||||
|
||||
for offset in [31_u64, 62, 93] {
|
||||
assert_eq!(
|
||||
observe_aria2_connections(
|
||||
&mut observation,
|
||||
"gid-1",
|
||||
"active",
|
||||
100 * 1024 * 1024,
|
||||
10 * 1024 * 1024 + offset as u64 * 1024,
|
||||
1024.0 * 1024.0,
|
||||
16,
|
||||
16,
|
||||
false,
|
||||
start + Duration::from_secs(offset),
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_metadata_fallback_lets_ytdlp_choose_extension() {
|
||||
let destination = std::path::Path::new("/tmp/firelink");
|
||||
@@ -6677,6 +6938,154 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Aria2RecoveryReason {
|
||||
ZeroProgress,
|
||||
SlowThroughput,
|
||||
ConnectionPoolCollapse,
|
||||
}
|
||||
|
||||
impl Aria2RecoveryReason {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ZeroProgress => "zero-stall",
|
||||
Self::SlowThroughput => "slow-throughput",
|
||||
Self::ConnectionPoolCollapse => "connection-collapse",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Aria2ConnectionObservation {
|
||||
gid: String,
|
||||
saw_multiple_connections: bool,
|
||||
healthy_speed_samples: u8,
|
||||
degraded_since: Option<Instant>,
|
||||
no_progress_since: Option<Instant>,
|
||||
last_refreshed_at: Option<Instant>,
|
||||
peak_speed_bytes: f64,
|
||||
last_completed: u64,
|
||||
}
|
||||
|
||||
const ARIA2_CONNECTION_RECOVERY_DELAY: Duration = Duration::from_secs(30);
|
||||
const ARIA2_CONNECTION_RECOVERY_COOLDOWN: Duration = Duration::from_secs(45);
|
||||
const ARIA2_MIN_REMAINING_FOR_CONNECTION_RECOVERY: u64 = 1024 * 1024;
|
||||
const ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY: f64 = 64.0 * 1024.0;
|
||||
const ARIA2_DEGRADED_SPEED_FRACTION: f64 = 0.20;
|
||||
const ARIA2_MIN_HEALTHY_SPEED_SAMPLES: u8 = 3;
|
||||
|
||||
fn observe_aria2_connections(
|
||||
observation: &mut Aria2ConnectionObservation,
|
||||
gid: &str,
|
||||
status: &str,
|
||||
total: u64,
|
||||
completed: u64,
|
||||
speed_bytes: f64,
|
||||
active_connections: i32,
|
||||
requested_connections: i32,
|
||||
speed_limited: bool,
|
||||
now: Instant,
|
||||
) -> Option<Aria2RecoveryReason> {
|
||||
if observation.gid != gid {
|
||||
*observation = Aria2ConnectionObservation {
|
||||
gid: gid.to_string(),
|
||||
last_completed: completed,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let remaining = total.saturating_sub(completed);
|
||||
let mut reason = None;
|
||||
if status == "active" && total > completed {
|
||||
let speed_bytes = speed_bytes.max(0.0);
|
||||
// A decaying peak keeps the trigger sensitive to a recent healthy
|
||||
// rate without permanently comparing against a brief startup burst.
|
||||
observation.peak_speed_bytes = (observation.peak_speed_bytes * 0.995).max(speed_bytes);
|
||||
|
||||
if completed > observation.last_completed {
|
||||
observation.no_progress_since = None;
|
||||
} else if speed_bytes <= 0.0 {
|
||||
let stalled_since = observation.no_progress_since.get_or_insert(now);
|
||||
if now.duration_since(*stalled_since) >= ARIA2_CONNECTION_RECOVERY_DELAY {
|
||||
reason = Some(Aria2RecoveryReason::ZeroProgress);
|
||||
}
|
||||
} else {
|
||||
observation.no_progress_since = None;
|
||||
}
|
||||
|
||||
let multi_connection_candidate = requested_connections > 1
|
||||
&& remaining >= ARIA2_MIN_REMAINING_FOR_CONNECTION_RECOVERY;
|
||||
if multi_connection_candidate {
|
||||
if active_connections > 1
|
||||
&& speed_bytes >= ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY
|
||||
&& speed_bytes >= observation.peak_speed_bytes * 0.5
|
||||
{
|
||||
observation.healthy_speed_samples = observation
|
||||
.healthy_speed_samples
|
||||
.saturating_add(1);
|
||||
}
|
||||
let slow_throughput = !speed_limited
|
||||
&& observation.saw_multiple_connections
|
||||
&& observation.healthy_speed_samples >= ARIA2_MIN_HEALTHY_SPEED_SAMPLES
|
||||
&& observation.peak_speed_bytes
|
||||
>= ARIA2_MIN_PEAK_SPEED_FOR_DEGRADED_RECOVERY
|
||||
&& speed_bytes > 0.0
|
||||
&& speed_bytes < observation.peak_speed_bytes * ARIA2_DEGRADED_SPEED_FRACTION;
|
||||
if slow_throughput {
|
||||
let degraded_since = observation.degraded_since.get_or_insert(now);
|
||||
if now.duration_since(*degraded_since) >= ARIA2_CONNECTION_RECOVERY_DELAY
|
||||
&& reason.is_none()
|
||||
{
|
||||
reason = Some(Aria2RecoveryReason::SlowThroughput);
|
||||
}
|
||||
} else {
|
||||
observation.degraded_since = None;
|
||||
}
|
||||
|
||||
if active_connections > 1 {
|
||||
observation.saw_multiple_connections = true;
|
||||
} else if !speed_limited && observation.saw_multiple_connections {
|
||||
let degraded_since = observation.degraded_since.get_or_insert(now);
|
||||
if now.duration_since(*degraded_since) >= ARIA2_CONNECTION_RECOVERY_DELAY
|
||||
&& reason.is_none()
|
||||
{
|
||||
reason = Some(Aria2RecoveryReason::ConnectionPoolCollapse);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
observation.degraded_since = None;
|
||||
}
|
||||
} else {
|
||||
observation.degraded_since = None;
|
||||
observation.no_progress_since = None;
|
||||
}
|
||||
|
||||
observation.last_completed = completed;
|
||||
|
||||
let recovery_allowed = observation.last_refreshed_at.map_or(true, |last| {
|
||||
now.duration_since(last) >= ARIA2_CONNECTION_RECOVERY_COOLDOWN
|
||||
});
|
||||
if !recovery_allowed {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(reason) = reason {
|
||||
observation.last_refreshed_at = Some(now);
|
||||
// Start a fresh observation window after every attempt. This permits
|
||||
// repeated healing while preventing a refresh loop every poll tick.
|
||||
match reason {
|
||||
Aria2RecoveryReason::ZeroProgress => observation.no_progress_since = Some(now),
|
||||
Aria2RecoveryReason::SlowThroughput
|
||||
| Aria2RecoveryReason::ConnectionPoolCollapse => {
|
||||
observation.degraded_since = Some(now)
|
||||
}
|
||||
}
|
||||
Some(reason)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
static LOG_PAUSED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
|
||||
static LOG_STREAM_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
@@ -6827,6 +7236,10 @@ pub fn run() {
|
||||
let scheduler_settings = Arc::new(RwLock::new(persisted_settings.clone()));
|
||||
|
||||
let queue_manager = Arc::new(queue::QueueManager::new(app.handle().clone(), max_concurrent));
|
||||
let initial_global_speed_limit = persisted_settings
|
||||
.as_ref()
|
||||
.and_then(|settings| normalize_speed_limit_for_aria2(&settings.global_speed_limit));
|
||||
queue_manager.set_aria2_global_speed_limit(initial_global_speed_limit);
|
||||
let dispatcher_mgr = Arc::clone(&queue_manager);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
dispatcher_mgr.run_dispatcher().await;
|
||||
@@ -7108,18 +7521,6 @@ pub fn run() {
|
||||
let poll_secret = aria2_secret.clone();
|
||||
let poll_mgr = Arc::clone(&queue_manager_poll);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
#[derive(Default)]
|
||||
struct Aria2ConnectionObservation {
|
||||
gid: String,
|
||||
saw_multiple_connections: bool,
|
||||
degraded_since: Option<Instant>,
|
||||
no_progress_since: Option<Instant>,
|
||||
refreshed: bool,
|
||||
last_completed: u64,
|
||||
}
|
||||
|
||||
const RECOVERY_DELAY: Duration = Duration::from_secs(30);
|
||||
const MIN_REMAINING_FOR_CONNECTION_RECOVERY: u64 = 1024 * 1024;
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(1000));
|
||||
let mut observations: HashMap<String, Aria2ConnectionObservation> = HashMap::new();
|
||||
loop {
|
||||
@@ -7150,64 +7551,21 @@ pub fn run() {
|
||||
.await
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let speed_limited = poll_mgr.aria2_speed_limited(&id).await;
|
||||
let now = Instant::now();
|
||||
let observation = observations.entry(id.clone()).or_default();
|
||||
if observation.gid != gid {
|
||||
*observation = Aria2ConnectionObservation {
|
||||
gid: gid.to_string(),
|
||||
last_completed: completed,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
|
||||
let remaining = total.saturating_sub(completed);
|
||||
let mut should_refresh = false;
|
||||
if status == "active" && total > completed {
|
||||
if completed > observation.last_completed {
|
||||
observation.no_progress_since = None;
|
||||
} else if speed_bytes <= 0.0 {
|
||||
let stalled_since = observation.no_progress_since.get_or_insert(now);
|
||||
if now.duration_since(*stalled_since) >= RECOVERY_DELAY
|
||||
&& !observation.refreshed
|
||||
{
|
||||
observation.refreshed = true;
|
||||
should_refresh = true;
|
||||
}
|
||||
} else {
|
||||
observation.no_progress_since = None;
|
||||
}
|
||||
|
||||
if requested_connections > 1 && active_connections > 1 {
|
||||
observation.saw_multiple_connections = true;
|
||||
observation.degraded_since = None;
|
||||
if !should_refresh {
|
||||
observation.refreshed = false;
|
||||
}
|
||||
} else if requested_connections > 1
|
||||
&& observation.saw_multiple_connections
|
||||
&& active_connections <= 1
|
||||
&& remaining >= MIN_REMAINING_FOR_CONNECTION_RECOVERY
|
||||
{
|
||||
let degraded_since = observation.degraded_since.get_or_insert(now);
|
||||
if now.duration_since(*degraded_since) >= RECOVERY_DELAY
|
||||
&& !observation.refreshed
|
||||
{
|
||||
observation.refreshed = true;
|
||||
should_refresh = true;
|
||||
log::warn!(
|
||||
"aria2 connection pool degraded [{}]: gid {} has {} connection(s), requested {}; refreshing",
|
||||
id,
|
||||
gid,
|
||||
active_connections,
|
||||
requested_connections
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
observation.degraded_since = None;
|
||||
observation.no_progress_since = None;
|
||||
}
|
||||
observation.last_completed = completed;
|
||||
let recovery_reason = observe_aria2_connections(
|
||||
observation,
|
||||
gid,
|
||||
status,
|
||||
total,
|
||||
completed,
|
||||
speed_bytes,
|
||||
active_connections,
|
||||
requested_connections,
|
||||
speed_limited,
|
||||
now,
|
||||
);
|
||||
|
||||
let fraction = if total > 0 { completed as f64 / total as f64 } else { 0.0 };
|
||||
let speed = crate::download::format_speed(speed_bytes);
|
||||
@@ -7235,10 +7593,19 @@ pub fn run() {
|
||||
total_is_estimate: Some(false),
|
||||
});
|
||||
|
||||
if should_refresh {
|
||||
if let Some(reason) = recovery_reason {
|
||||
log::warn!(
|
||||
"aria2 connection recovery [{}]: gid {} reason={} speed={}B/s active_connections={} requested_connections={}",
|
||||
id,
|
||||
gid,
|
||||
reason.as_str(),
|
||||
speed_bytes,
|
||||
active_connections,
|
||||
requested_connections
|
||||
);
|
||||
if let Err(error) = poll_mgr.refresh_aria2_connections(&id, gid).await {
|
||||
log::warn!(
|
||||
"aria2 connection refresh [{}] for gid {} failed: {}",
|
||||
"aria2 connection recovery [{}] for gid {} failed: {}",
|
||||
id,
|
||||
gid,
|
||||
error
|
||||
|
||||
@@ -156,6 +156,11 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
|
||||
/// download id -> spawn payload for aria2 transient-error re-addUri retries.
|
||||
aria2_payloads: Mutex<HashMap<String, SpawnPayload>>,
|
||||
|
||||
/// The daemon-wide download cap currently applied to aria2. This mirrors
|
||||
/// successful RPC changes so the poller can avoid treating an intentional
|
||||
/// cap as a degraded connection pool.
|
||||
aria2_global_speed_limit: Arc<StdMutex<Option<String>>>,
|
||||
|
||||
/// 0-based transient-error strike counter per aria2 download id.
|
||||
aria2_retry_strikes: Mutex<HashMap<String, usize>>,
|
||||
|
||||
@@ -221,6 +226,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
||||
pending_completion: Arc::new(Mutex::new(HashMap::new())),
|
||||
aria2_payloads: Mutex::new(HashMap::new()),
|
||||
aria2_global_speed_limit: Arc::new(StdMutex::new(None)),
|
||||
aria2_retry_strikes: Mutex::new(HashMap::new()),
|
||||
aria2_retry_cancelled: Mutex::new(HashSet::new()),
|
||||
aria2_retry_inflight: Mutex::new(HashMap::new()),
|
||||
@@ -467,6 +473,32 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
.and_then(|payload| payload.connections)
|
||||
}
|
||||
|
||||
pub fn set_aria2_global_speed_limit(&self, limit: Option<String>) {
|
||||
*self
|
||||
.aria2_global_speed_limit
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner()) = limit;
|
||||
}
|
||||
|
||||
pub async fn aria2_speed_limited(&self, id: &str) -> bool {
|
||||
if self
|
||||
.aria2_global_speed_limit
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
self.aria2_payloads
|
||||
.lock()
|
||||
.await
|
||||
.get(id)
|
||||
.and_then(|payload| payload.speed_limit.as_deref())
|
||||
.and_then(crate::normalize_speed_limit_for_aria2)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Pop the next task, or None if empty.
|
||||
pub async fn pop_front(&self) -> Option<QueuedTask> {
|
||||
self.pending.lock().await.pop_front()
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
formatDownloadTotal,
|
||||
resolveDownloadSizeDisplay
|
||||
} from '../utils/downloadProgress';
|
||||
import { resolveDownloadConnections } from '../utils/downloads';
|
||||
|
||||
type LoginMode = 'matching' | 'custom' | 'none';
|
||||
|
||||
@@ -46,7 +47,8 @@ export const PropertiesModal = () => {
|
||||
const [url, setUrl] = useState('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [saveLocation, setSaveLocation] = useState('');
|
||||
const [connections, setConnections] = useState(16);
|
||||
const [connections, setConnections] = useState(() => resolveDownloadConnections(undefined, perServerConnections));
|
||||
const [connectionsDirty, setConnectionsDirty] = useState(false);
|
||||
|
||||
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
|
||||
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
|
||||
@@ -79,7 +81,8 @@ export const PropertiesModal = () => {
|
||||
activeItem.category
|
||||
).then(setSaveLocation);
|
||||
}
|
||||
setConnections(activeItem.connections || 16);
|
||||
setConnections(resolveDownloadConnections(activeItem.connections, perServerConnections));
|
||||
setConnectionsDirty(false);
|
||||
|
||||
if (activeItem.speedLimit) {
|
||||
setSpeedLimitEnabled(true);
|
||||
@@ -117,7 +120,15 @@ export const PropertiesModal = () => {
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
}
|
||||
}
|
||||
}, [selectedPropertiesDownloadId, baseDownloadFolder, setSelectedPropertiesDownloadId]);
|
||||
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPropertiesDownloadId || connectionsDirty) return;
|
||||
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
|
||||
if (activeItem && activeItem.connections === undefined) {
|
||||
setConnections(resolveDownloadConnections(undefined, perServerConnections));
|
||||
}
|
||||
}, [selectedPropertiesDownloadId, perServerConnections, connectionsDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPropertiesDownloadId) return;
|
||||
@@ -160,7 +171,6 @@ export const PropertiesModal = () => {
|
||||
url,
|
||||
fileName,
|
||||
destination: saveLocation,
|
||||
connections: Number(connections),
|
||||
speedLimit: speedLimitEnabled && speedLimitValue ? `${speedLimitValue}K` : undefined,
|
||||
username: loginMode === 'custom' ? username.trim() : undefined,
|
||||
password: loginMode === 'custom' ? password.trim() : undefined,
|
||||
@@ -168,6 +178,9 @@ export const PropertiesModal = () => {
|
||||
checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined,
|
||||
cookies: cookies.trim() || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
...(connectionsDirty
|
||||
? { connections: resolveDownloadConnections(connections, perServerConnections) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -259,7 +272,7 @@ export const PropertiesModal = () => {
|
||||
<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-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate">{item.connections || perServerConnections || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate" title={item.connections !== undefined ? 'Saved for this download; Settings changes apply to new downloads.' : 'Using the current default for new downloads.'}>{resolveDownloadConnections(item.connections, perServerConnections)}{item.connections !== undefined ? ' (saved)' : ' (default)'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">Speed cap</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[55px] shrink-0">Category</span><span className="text-text-secondary truncate">{item.category}</span></div>
|
||||
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">{formatLastTry(item.lastTry)}</span></div>
|
||||
@@ -311,8 +324,20 @@ export const PropertiesModal = () => {
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Connections</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="number" value={connections} min={1} max={16} onChange={e=>setConnections(Number(e.target.value))} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<input type="number" value={connections} min={1} max={16} onChange={e=>{ setConnections(Number(e.target.value)); setConnectionsDirty(true); }} disabled={transferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
|
||||
<span className="text-xs text-text-muted">per file</span>
|
||||
{!transferLocked && item.connections !== undefined && item.connections !== perServerConnections && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setConnections(perServerConnections); setConnectionsDirty(true); }}
|
||||
className="text-[11px] text-accent hover:underline whitespace-nowrap"
|
||||
>
|
||||
Use current default ({perServerConnections})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-start-2 text-[11px] text-text-muted">
|
||||
Saved per download. The Settings default applies to new downloads.
|
||||
</div>
|
||||
|
||||
<label className="text-xs text-text-muted text-right">Speed</label>
|
||||
|
||||
@@ -625,7 +625,7 @@ runEngineChecks(false);
|
||||
<div className="mac-settings-row">
|
||||
<div className="settings-row-label">
|
||||
<span>Default connections:</span>
|
||||
<small>For new downloads</small>
|
||||
<small>New downloads; existing items keep their saved value</small>
|
||||
</div>
|
||||
<input
|
||||
type="number" min="1" max="16"
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ExtensionDownload } from '../bindings/ExtensionDownload';
|
||||
import type { Queue } from '../bindings/Queue';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
|
||||
import { categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
|
||||
import {
|
||||
resolveCategoryDestination
|
||||
} from '../utils/downloadLocations';
|
||||
@@ -201,7 +201,7 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
||||
url: item.url,
|
||||
destination,
|
||||
filename: item.fileName,
|
||||
connections: item.connections || settings.perServerConnections || null,
|
||||
connections: resolveDownloadConnections(item.connections, settings.perServerConnections),
|
||||
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
||||
username: item.username || (login ? login.username : null),
|
||||
password: item.password || keychainPassword,
|
||||
@@ -694,6 +694,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
const queuePosition = maxPos + 1;
|
||||
const ownedItem: DownloadItem = {
|
||||
...item,
|
||||
connections: resolveDownloadConnections(item.connections, settings.perServerConnections),
|
||||
destination: destPath,
|
||||
status: action.type === 'add-to-queue' ? 'staged' : 'ready',
|
||||
queueId,
|
||||
@@ -1177,7 +1178,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
url: item.url,
|
||||
destination: destPath,
|
||||
filename: item.fileName,
|
||||
connections: item.connections || settings.perServerConnections || null,
|
||||
connections: resolveDownloadConnections(item.connections, settings.perServerConnections),
|
||||
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
||||
username: item.username || (login ? login.username : null),
|
||||
password: item.password || keychainPassword,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import { redactDownloadForPersistence } from './downloads';
|
||||
import { redactDownloadForPersistence, resolveDownloadConnections } from './downloads';
|
||||
|
||||
const item = (status: DownloadItem['status']): DownloadItem => ({
|
||||
id: 'download-1',
|
||||
@@ -42,3 +42,17 @@ describe('download persistence progress snapshots', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('download connection resolution', () => {
|
||||
it('uses a clamped fallback for legacy rows without a saved value', () => {
|
||||
expect(resolveDownloadConnections(undefined, 8)).toBe(8);
|
||||
expect(resolveDownloadConnections(undefined, 0)).toBe(1);
|
||||
expect(resolveDownloadConnections(undefined, Number.NaN)).toBe(16);
|
||||
});
|
||||
|
||||
it('clamps malformed saved values before dispatch', () => {
|
||||
expect(resolveDownloadConnections(0, 8)).toBe(1);
|
||||
expect(resolveDownloadConnections(17, 8)).toBe(16);
|
||||
expect(resolveDownloadConnections(Number.NaN, 8)).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,37 @@ export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
|
||||
export const isTransferActiveStatus = (status: DownloadStatus): boolean =>
|
||||
status === 'downloading' || status === 'processing' || status === 'retrying';
|
||||
|
||||
export const DOWNLOAD_CONNECTIONS_MIN = 1;
|
||||
export const DOWNLOAD_CONNECTIONS_MAX = 16;
|
||||
|
||||
/**
|
||||
* Resolve persisted/user-entered connection values before they cross into the
|
||||
* backend. Older rows may omit the value, while malformed rows can contain
|
||||
* zero, NaN, or an out-of-range number.
|
||||
*/
|
||||
export const resolveDownloadConnections = (value: unknown, fallback: unknown): number => {
|
||||
const toFiniteInteger = (candidate: unknown): number | undefined => {
|
||||
if (typeof candidate === 'number') {
|
||||
return Number.isFinite(candidate) ? Math.trunc(candidate) : undefined;
|
||||
}
|
||||
if (typeof candidate === 'string' && candidate.trim() !== '') {
|
||||
const parsed = Number(candidate);
|
||||
return Number.isFinite(parsed) ? Math.trunc(parsed) : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const normalizedFallback = toFiniteInteger(fallback) ?? DOWNLOAD_CONNECTIONS_MAX;
|
||||
const safeFallback = Math.min(
|
||||
DOWNLOAD_CONNECTIONS_MAX,
|
||||
Math.max(DOWNLOAD_CONNECTIONS_MIN, normalizedFallback)
|
||||
);
|
||||
const candidate = toFiniteInteger(value) ?? safeFallback;
|
||||
return Math.min(
|
||||
DOWNLOAD_CONNECTIONS_MAX,
|
||||
Math.max(DOWNLOAD_CONNECTIONS_MIN, candidate)
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeSpeedLimitForBackend = (value?: string | null): string | null => {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
Reference in New Issue
Block a user