mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-05 17:08:26 +00:00
feat(torrents): add live peer controls
This commit is contained in:
@@ -185,6 +185,12 @@ pub struct DownloadItem {
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_upload_limit: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_max_peers: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_peer_speed_limit: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
|
||||
+14
-1
@@ -6300,6 +6300,19 @@ async fn set_torrent_upload_limit(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_torrent_peer_options(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
max_peers: Option<i64>,
|
||||
peer_speed_limit: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
state
|
||||
.queue_manager
|
||||
.set_aria2_torrent_peer_options(&id, max_peers, peer_speed_limit)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option<String> {
|
||||
let trimmed = limit.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -10679,7 +10692,7 @@ pub fn run() {
|
||||
authorize_keychain_access,
|
||||
acknowledge_pairing_token_change,
|
||||
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
||||
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path,
|
||||
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, set_global_speed_limit, remove_download, get_download_primary_path,
|
||||
detach_download_for_reconfigure,
|
||||
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
||||
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
||||
|
||||
@@ -212,6 +212,8 @@ pub struct SpawnPayload {
|
||||
pub torrent_seed_time: Option<f64>,
|
||||
pub torrent_seed_ratio: Option<f64>,
|
||||
pub torrent_upload_limit: Option<String>,
|
||||
pub torrent_max_peers: Option<u32>,
|
||||
pub torrent_peer_speed_limit: Option<String>,
|
||||
}
|
||||
|
||||
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
|
||||
@@ -268,6 +270,17 @@ pub trait SidecarSpawner: Send + Sync + 'static {
|
||||
Err("live torrent upload limits are unavailable".to_string())
|
||||
}
|
||||
|
||||
/// Change the peer cap and low-speed peer expansion threshold without
|
||||
/// replacing the Torrent GID or queue permit.
|
||||
async fn set_torrent_peer_options(
|
||||
&self,
|
||||
_gid: &str,
|
||||
_max_peers: u32,
|
||||
_peer_speed_limit: &str,
|
||||
) -> Result<(), String> {
|
||||
Err("live torrent peer options are unavailable".to_string())
|
||||
}
|
||||
|
||||
/// Run a media download to completion. The permit is parked for the full
|
||||
/// duration; release is handled by QueueManager on the runner's exit.
|
||||
async fn run_media(
|
||||
@@ -877,6 +890,78 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change active Torrent peer settings without replacing the GID or queue
|
||||
/// permit. Clearing a setting restores Aria2's documented default while
|
||||
/// keeping the persisted payload override-free for future retries.
|
||||
pub async fn set_aria2_torrent_peer_options(
|
||||
&self,
|
||||
id: &str,
|
||||
max_peers: Option<i64>,
|
||||
peer_speed_limit: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let normalized_max_peers = normalize_torrent_max_peers(max_peers)?;
|
||||
let normalized_peer_speed_limit =
|
||||
normalize_torrent_peer_speed_limit(peer_speed_limit.as_deref())?;
|
||||
let rpc_max_peers = normalized_max_peers.unwrap_or(ARIA2_DEFAULT_TORRENT_MAX_PEERS);
|
||||
let rpc_peer_speed_limit = normalized_peer_speed_limit
|
||||
.as_deref()
|
||||
.unwrap_or(ARIA2_DEFAULT_TORRENT_PEER_SPEED_LIMIT);
|
||||
let _control_guard = self.acquire_aria2_control(id).await;
|
||||
|
||||
if !self.is_registered(id).await
|
||||
|| !matches!(self.active_kind(id).await, Some(TaskKind::Aria2))
|
||||
{
|
||||
return Err("download is not an active aria2 transfer".to_string());
|
||||
}
|
||||
let is_torrent = self
|
||||
.aria2_payloads
|
||||
.lock()
|
||||
.await
|
||||
.get(id)
|
||||
.is_some_and(|payload| payload.is_torrent);
|
||||
if !is_torrent {
|
||||
return Err("download is not a Torrent transfer".to_string());
|
||||
}
|
||||
let gid = self
|
||||
.aria2_gid_for_download(id)
|
||||
.ok_or_else(|| "active Torrent transfer has no gid".to_string())?;
|
||||
let expected_mapping = self
|
||||
.aria2_gid_mapping(&gid)
|
||||
.ok_or_else(|| "active Torrent transfer has no current gid mapping".to_string())?;
|
||||
if expected_mapping.id != id {
|
||||
return Err("aria2 gid belongs to another download".to_string());
|
||||
}
|
||||
if !self
|
||||
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
|
||||
.await
|
||||
{
|
||||
return Err("active Torrent transfer has a stale control epoch".to_string());
|
||||
}
|
||||
|
||||
self.spawner
|
||||
.set_torrent_peer_options(&gid, rpc_max_peers, rpc_peer_speed_limit)
|
||||
.await?;
|
||||
|
||||
let still_current = self.is_registered(id).await
|
||||
&& matches!(self.active_kind(id).await, Some(TaskKind::Aria2))
|
||||
&& self
|
||||
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
|
||||
.await
|
||||
&& self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
|
||||
&& self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str());
|
||||
if !still_current {
|
||||
return Err("Torrent lifecycle changed while setting peer options".to_string());
|
||||
}
|
||||
|
||||
let mut payloads = self.aria2_payloads.lock().await;
|
||||
let payload = payloads
|
||||
.get_mut(id)
|
||||
.ok_or_else(|| "active Torrent transfer payload is unavailable".to_string())?;
|
||||
payload.torrent_max_peers = normalized_max_peers;
|
||||
payload.torrent_peer_speed_limit = normalized_peer_speed_limit;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pop the next task, or None if empty.
|
||||
pub async fn pop_front(&self) -> Option<QueuedTask> {
|
||||
self.pending.lock().await.pop_front()
|
||||
@@ -2958,6 +3043,9 @@ pub struct ProductionSpawner {
|
||||
|
||||
const ARIA2_MIN_SPLIT_SIZE: &str = "1M";
|
||||
const ARIA2_STREAM_PIECE_SELECTOR: &str = "inorder";
|
||||
const ARIA2_DEFAULT_TORRENT_MAX_PEERS: u32 = 55;
|
||||
const ARIA2_DEFAULT_TORRENT_PEER_SPEED_LIMIT: &str = "50K";
|
||||
const MAX_TORRENT_MAX_PEERS: u32 = 1000;
|
||||
|
||||
fn apply_aria2_connection_options(
|
||||
options: &mut serde_json::Map<String, serde_json::Value>,
|
||||
@@ -2996,6 +3084,27 @@ fn format_aria2_torrent_number(value: f64, field: &str) -> Result<String, String
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn normalize_torrent_max_peers(value: Option<i64>) -> Result<Option<u32>, String> {
|
||||
let Some(value) = value else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !(0..=i64::from(MAX_TORRENT_MAX_PEERS)).contains(&value) {
|
||||
return Err(format!(
|
||||
"torrent maximum peers must be between 0 and {MAX_TORRENT_MAX_PEERS}"
|
||||
));
|
||||
}
|
||||
Ok(Some(value as u32))
|
||||
}
|
||||
|
||||
fn normalize_torrent_peer_speed_limit(value: Option<&str>) -> Result<Option<String>, String> {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
crate::normalize_speed_limit_for_aria2(value)
|
||||
.map(Some)
|
||||
.ok_or_else(|| "torrent peer speed limit must be greater than zero".to_string())
|
||||
}
|
||||
|
||||
fn apply_aria2_torrent_options(
|
||||
options: &mut serde_json::Map<String, serde_json::Value>,
|
||||
payload: &SpawnPayload,
|
||||
@@ -3033,6 +3142,27 @@ fn apply_aria2_torrent_options(
|
||||
.ok_or_else(|| "torrent upload limit must be greater than zero".to_string())?;
|
||||
options.insert("max-upload-limit".to_string(), serde_json::json!(normalized));
|
||||
}
|
||||
|
||||
if let Some(max_peers) = payload.torrent_max_peers {
|
||||
if max_peers > MAX_TORRENT_MAX_PEERS {
|
||||
return Err(format!(
|
||||
"torrent maximum peers must be between 0 and {MAX_TORRENT_MAX_PEERS}"
|
||||
));
|
||||
}
|
||||
options.insert(
|
||||
"bt-max-peers".to_string(),
|
||||
serde_json::json!(max_peers.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(peer_speed_limit) = payload.torrent_peer_speed_limit.as_deref() {
|
||||
let normalized = normalize_torrent_peer_speed_limit(Some(peer_speed_limit))
|
||||
?.ok_or_else(|| "torrent peer speed limit must be greater than zero".to_string())?;
|
||||
options.insert(
|
||||
"bt-request-peer-speed-limit".to_string(),
|
||||
serde_json::json!(normalized),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3289,6 +3419,33 @@ impl SidecarSpawner for ProductionSpawner {
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_torrent_peer_options(
|
||||
&self,
|
||||
gid: &str,
|
||||
max_peers: u32,
|
||||
peer_speed_limit: &str,
|
||||
) -> Result<(), String> {
|
||||
let state = self.app_handle.state::<crate::AppState>();
|
||||
let result = crate::rpc_call(
|
||||
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
|
||||
&state.aria2_secret,
|
||||
"aria2.changeOption",
|
||||
serde_json::json!([gid, {
|
||||
"bt-max-peers": max_peers.to_string(),
|
||||
"bt-request-peer-speed-limit": peer_speed_limit,
|
||||
}]),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("aria2.changeOption failed for gid {gid}: {error}"))?;
|
||||
match result.as_str() {
|
||||
Some("OK") => Ok(()),
|
||||
Some(value) => Err(format!(
|
||||
"aria2.changeOption returned unexpected result {value} for gid {gid}"
|
||||
)),
|
||||
None => Err("aria2.changeOption returned a non-string result".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn recreate_uri(
|
||||
&self,
|
||||
id: &str,
|
||||
@@ -3589,6 +3746,12 @@ pub struct EnqueueItem {
|
||||
pub torrent_upload_limit: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_max_peers: Option<u32>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub torrent_peer_speed_limit: Option<String>,
|
||||
#[serde(default)]
|
||||
#[ts(optional)]
|
||||
pub lifecycle_generation: Option<String>,
|
||||
}
|
||||
|
||||
@@ -3634,6 +3797,8 @@ impl EnqueueItem {
|
||||
torrent_seed_time: self.torrent_seed_time,
|
||||
torrent_seed_ratio: self.torrent_seed_ratio,
|
||||
torrent_upload_limit: self.torrent_upload_limit,
|
||||
torrent_max_peers: self.torrent_max_peers,
|
||||
torrent_peer_speed_limit: self.torrent_peer_speed_limit,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3708,6 +3873,8 @@ mod tests {
|
||||
torrent_seed_time: Some(30.0),
|
||||
torrent_seed_ratio: Some(1.5),
|
||||
torrent_upload_limit: Some("2 MiB/s".to_string()),
|
||||
torrent_max_peers: Some(120),
|
||||
torrent_peer_speed_limit: Some("2M".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -3719,6 +3886,14 @@ mod tests {
|
||||
options.get("max-upload-limit"),
|
||||
Some(&serde_json::json!("2M"))
|
||||
);
|
||||
assert_eq!(
|
||||
options.get("bt-max-peers"),
|
||||
Some(&serde_json::json!("120"))
|
||||
);
|
||||
assert_eq!(
|
||||
options.get("bt-request-peer-speed-limit"),
|
||||
Some(&serde_json::json!("2M"))
|
||||
);
|
||||
assert!(torrent_seeding_requested(&payload));
|
||||
}
|
||||
|
||||
@@ -3751,6 +3926,27 @@ mod tests {
|
||||
assert!(error.contains("seed time"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torrent_options_reject_invalid_peer_values() {
|
||||
let mut options = serde_json::Map::new();
|
||||
let payload = SpawnPayload {
|
||||
is_torrent: true,
|
||||
torrent_max_peers: Some(MAX_TORRENT_MAX_PEERS + 1),
|
||||
..Default::default()
|
||||
};
|
||||
let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err();
|
||||
assert!(error.contains("maximum peers"));
|
||||
|
||||
let mut options = serde_json::Map::new();
|
||||
let payload = SpawnPayload {
|
||||
is_torrent: true,
|
||||
torrent_peer_speed_limit: Some("0".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err();
|
||||
assert!(error.contains("peer speed limit"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeding_outcome_keeps_torrent_ownership_and_permit_live() {
|
||||
let app = tauri::test::mock_builder()
|
||||
|
||||
@@ -20,7 +20,13 @@ struct CountingSpawner {
|
||||
block_torrent_upload_limit: std::sync::atomic::AtomicBool,
|
||||
torrent_upload_limit_started: tokio::sync::Notify,
|
||||
torrent_upload_limit_release: tokio::sync::Notify,
|
||||
torrent_peer_options_calls: AtomicUsize,
|
||||
last_torrent_peer_options: std::sync::Mutex<Option<(u32, String)>>,
|
||||
block_torrent_peer_options: std::sync::atomic::AtomicBool,
|
||||
torrent_peer_options_started: tokio::sync::Notify,
|
||||
torrent_peer_options_release: tokio::sync::Notify,
|
||||
add_speed_limits: std::sync::Mutex<Vec<Option<String>>>,
|
||||
add_peer_options: std::sync::Mutex<Vec<(Option<u32>, Option<String>)>>,
|
||||
block_speed_limit: std::sync::atomic::AtomicBool,
|
||||
speed_limit_started: tokio::sync::Notify,
|
||||
speed_limit_release: tokio::sync::Notify,
|
||||
@@ -198,7 +204,13 @@ impl CountingSpawner {
|
||||
block_torrent_upload_limit: std::sync::atomic::AtomicBool::new(false),
|
||||
torrent_upload_limit_started: tokio::sync::Notify::new(),
|
||||
torrent_upload_limit_release: tokio::sync::Notify::new(),
|
||||
torrent_peer_options_calls: AtomicUsize::new(0),
|
||||
last_torrent_peer_options: std::sync::Mutex::new(None),
|
||||
block_torrent_peer_options: std::sync::atomic::AtomicBool::new(false),
|
||||
torrent_peer_options_started: tokio::sync::Notify::new(),
|
||||
torrent_peer_options_release: tokio::sync::Notify::new(),
|
||||
add_speed_limits: std::sync::Mutex::new(Vec::new()),
|
||||
add_peer_options: std::sync::Mutex::new(Vec::new()),
|
||||
block_speed_limit: std::sync::atomic::AtomicBool::new(false),
|
||||
speed_limit_started: tokio::sync::Notify::new(),
|
||||
speed_limit_release: tokio::sync::Notify::new(),
|
||||
@@ -275,6 +287,13 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(payload.speed_limit.clone());
|
||||
self.add_peer_options
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((
|
||||
payload.torrent_max_peers,
|
||||
payload.torrent_peer_speed_limit.clone(),
|
||||
));
|
||||
Ok(format!("gid-{call}"))
|
||||
}
|
||||
async fn remove_uri(&self, _gid: &str) -> Result<(), String> {
|
||||
@@ -312,6 +331,24 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn set_torrent_peer_options(
|
||||
&self,
|
||||
_gid: &str,
|
||||
max_peers: u32,
|
||||
peer_speed_limit: &str,
|
||||
) -> Result<(), String> {
|
||||
self.torrent_peer_options_calls.fetch_add(1, Ordering::SeqCst);
|
||||
*self.last_torrent_peer_options.lock().unwrap() =
|
||||
Some((max_peers, peer_speed_limit.to_string()));
|
||||
if self
|
||||
.block_torrent_peer_options
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
{
|
||||
self.torrent_peer_options_started.notify_one();
|
||||
self.torrent_peer_options_release.notified().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn run_media(&self, _id: &str, _payload: &SpawnPayload, _generation: u64) -> Result<(), String> {
|
||||
self.media_calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
@@ -736,6 +773,137 @@ async fn live_torrent_upload_limit_does_not_update_after_gid_replacement() {
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_torrent_peer_options_update_current_gid_and_payload() {
|
||||
let (manager, spawner) = make_manager(1);
|
||||
let manager = Arc::new(manager);
|
||||
let mut task = aria2_task("torrent-peer-options");
|
||||
task.payload.is_torrent = true;
|
||||
manager.push(task).await.unwrap();
|
||||
|
||||
let dispatcher = {
|
||||
let manager = Arc::clone(&manager);
|
||||
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||
};
|
||||
timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
if manager.aria2_gid_for_download("torrent-peer-options").is_some() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("aria2 dispatch should register a Torrent gid");
|
||||
|
||||
manager
|
||||
.set_aria2_torrent_peer_options(
|
||||
"torrent-peer-options",
|
||||
Some(120),
|
||||
Some("2M".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(spawner.torrent_peer_options_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
spawner.last_torrent_peer_options.lock().unwrap().as_ref(),
|
||||
Some(&(120, "2M".to_string()))
|
||||
);
|
||||
manager
|
||||
.set_aria2_torrent_peer_options("torrent-peer-options", None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(spawner.torrent_peer_options_calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(
|
||||
spawner.last_torrent_peer_options.lock().unwrap().as_ref(),
|
||||
Some(&(55, "50K".to_string()))
|
||||
);
|
||||
manager.clear_aria2_retry_state("torrent-peer-options").await;
|
||||
manager.forget_aria2_gid("torrent-peer-options").await;
|
||||
manager.release_permit("torrent-peer-options").await;
|
||||
manager.release_registered_id("torrent-peer-options").await;
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn live_torrent_peer_options_reject_invalid_values_and_stale_gid() {
|
||||
let (manager, spawner) = make_manager(1);
|
||||
let manager = Arc::new(manager);
|
||||
let mut task = aria2_task("torrent-peer-options-stale");
|
||||
task.payload.is_torrent = true;
|
||||
manager.push(task).await.unwrap();
|
||||
let dispatcher = {
|
||||
let manager = Arc::clone(&manager);
|
||||
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||
};
|
||||
timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
if manager
|
||||
.aria2_gid_for_download("torrent-peer-options-stale")
|
||||
.is_some()
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("aria2 dispatch should register a Torrent gid");
|
||||
|
||||
assert!(manager
|
||||
.set_aria2_torrent_peer_options(
|
||||
"torrent-peer-options-stale",
|
||||
Some(1001),
|
||||
Some("2M".to_string()),
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(manager
|
||||
.set_aria2_torrent_peer_options(
|
||||
"torrent-peer-options-stale",
|
||||
Some(100),
|
||||
Some("not-a-rate".to_string()),
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(spawner.torrent_peer_options_calls.load(Ordering::SeqCst), 0);
|
||||
|
||||
spawner
|
||||
.block_torrent_peer_options
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let started = spawner.torrent_peer_options_started.notified();
|
||||
let setter = {
|
||||
let manager = Arc::clone(&manager);
|
||||
tokio::spawn(async move {
|
||||
manager
|
||||
.set_aria2_torrent_peer_options(
|
||||
"torrent-peer-options-stale",
|
||||
Some(100),
|
||||
Some("2M".to_string()),
|
||||
)
|
||||
.await
|
||||
})
|
||||
};
|
||||
timeout(Duration::from_secs(1), started)
|
||||
.await
|
||||
.expect("Torrent peer options RPC should start");
|
||||
|
||||
manager
|
||||
.remember_gid(
|
||||
"torrent-peer-options-stale".to_string(),
|
||||
"gid-replaced".to_string(),
|
||||
)
|
||||
.await;
|
||||
spawner.torrent_peer_options_release.notify_one();
|
||||
assert!(setter.await.unwrap().is_err());
|
||||
|
||||
manager.clear_aria2_retry_state("torrent-peer-options-stale").await;
|
||||
manager.forget_aria2_gid("torrent-peer-options-stale").await;
|
||||
manager.release_permit("torrent-peer-options-stale").await;
|
||||
manager.release_registered_id("torrent-peer-options-stale").await;
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_readds_aria2_with_the_latest_live_speed_limit() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
@@ -798,6 +966,80 @@ async fn retry_readds_aria2_with_the_latest_live_speed_limit() {
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_readds_torrent_with_the_latest_live_peer_options() {
|
||||
use firelink_lib::queue::PendingOutcome;
|
||||
|
||||
let (mgr, spawner) = make_manager(1);
|
||||
let manager = Arc::new(mgr);
|
||||
let mut task = aria2_task("peer-options-retry");
|
||||
task.payload.is_torrent = true;
|
||||
task.payload.max_tries = Some(1);
|
||||
task.payload.torrent_max_peers = Some(120);
|
||||
task.payload.torrent_peer_speed_limit = Some("1M".to_string());
|
||||
manager.push(task).await.unwrap();
|
||||
let dispatcher = {
|
||||
let manager = Arc::clone(&manager);
|
||||
tokio::spawn(async move { manager.run_dispatcher().await })
|
||||
};
|
||||
|
||||
timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
if spawner.add_uri_calls.load(Ordering::SeqCst) >= 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("initial Torrent add should run");
|
||||
|
||||
manager
|
||||
.handle_aria2_event(
|
||||
"gid-1",
|
||||
PendingOutcome::Error(
|
||||
"aria2 error code 1: Failed to receive data, cause: protocol error".to_string(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
manager
|
||||
.set_aria2_torrent_peer_options(
|
||||
"peer-options-retry",
|
||||
Some(240),
|
||||
Some("2M".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
timeout(Duration::from_secs(4), async {
|
||||
loop {
|
||||
if spawner.add_uri_calls.load(Ordering::SeqCst) >= 2 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("Torrent retry should re-add after backoff");
|
||||
assert_eq!(
|
||||
spawner.add_peer_options.lock().unwrap().as_slice(),
|
||||
&[
|
||||
(Some(120), Some("1M".to_string())),
|
||||
(Some(240), Some("2M".to_string()))
|
||||
]
|
||||
);
|
||||
|
||||
manager
|
||||
.clear_aria2_retry_state("peer-options-retry")
|
||||
.await;
|
||||
manager
|
||||
.forget_aria2_gid("peer-options-retry")
|
||||
.await;
|
||||
manager.release_permit("peer-options-retry").await;
|
||||
manager.release_registered_id("peer-options-retry").await;
|
||||
dispatcher.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_aria2_permit_candidate_cannot_replace_current_permit() {
|
||||
let (mgr, _spawner) = make_manager(2);
|
||||
|
||||
@@ -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, 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, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: 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, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: 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 EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, lifecycle_generation?: string, };
|
||||
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, lifecycle_generation?: string, };
|
||||
|
||||
@@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch } from '../utils/downloads';
|
||||
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, normalizeSpeedLimitForBackend } from '../utils/downloads';
|
||||
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
|
||||
import {
|
||||
expandTilde,
|
||||
@@ -230,6 +230,8 @@ export const AddDownloadsModal = () => {
|
||||
const [torrentSeedRatio, setTorrentSeedRatio] = useState('1.0');
|
||||
const [torrentUploadLimitEnabled, setTorrentUploadLimitEnabled] = useState(false);
|
||||
const [torrentUploadLimit, setTorrentUploadLimit] = useState('1024');
|
||||
const [torrentMaxPeers, setTorrentMaxPeers] = useState('');
|
||||
const [torrentPeerSpeedLimit, setTorrentPeerSpeedLimit] = useState('');
|
||||
const [freeSpace, setFreeSpace] = useState('Unknown');
|
||||
const freeSpaceRequestRef = useRef(0);
|
||||
|
||||
@@ -368,6 +370,8 @@ export const AddDownloadsModal = () => {
|
||||
setTorrentSeedRatio('1.0');
|
||||
setTorrentUploadLimitEnabled(false);
|
||||
setTorrentUploadLimit('1024');
|
||||
setTorrentMaxPeers('');
|
||||
setTorrentPeerSpeedLimit('');
|
||||
setUseAuth(false);
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
@@ -954,6 +958,18 @@ export const AddDownloadsModal = () => {
|
||||
addToast({ message: t($ => $.addDownloads.torrentUploadLimitInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
hasSelectedTorrent
|
||||
&& torrentMaxPeers.trim()
|
||||
&& (!Number.isInteger(Number(torrentMaxPeers)) || Number(torrentMaxPeers) < 0 || Number(torrentMaxPeers) > 1000)
|
||||
) {
|
||||
addToast({ message: t($ => $.addDownloads.torrentMaxPeersInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (hasSelectedTorrent && torrentPeerSpeedLimit.trim() && !normalizeSpeedLimitForBackend(torrentPeerSpeedLimit)) {
|
||||
addToast({ message: t($ => $.addDownloads.torrentPeerSpeedLimitInvalid), variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (saveInDedicatedFolder && !sanitizeBatchFolderName(dedicatedFolderName)) {
|
||||
addToast({
|
||||
message: t($ => $.addDownloads.dedicatedFolderNameRequired),
|
||||
@@ -1428,6 +1444,10 @@ export const AddDownloadsModal = () => {
|
||||
torrentSeedTime: item.isTorrent && torrentSeedingEnabled ? Number(torrentSeedTime) : undefined,
|
||||
torrentSeedRatio: item.isTorrent && torrentSeedingEnabled ? Number(torrentSeedRatio) : undefined,
|
||||
torrentUploadLimit: item.isTorrent && torrentUploadLimitEnabled ? `${torrentUploadLimit}K` : undefined,
|
||||
torrentMaxPeers: item.isTorrent && torrentMaxPeers.trim() ? Number(torrentMaxPeers) : undefined,
|
||||
torrentPeerSpeedLimit: item.isTorrent
|
||||
? normalizeSpeedLimitForBackend(torrentPeerSpeedLimit) || undefined
|
||||
: undefined,
|
||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
|
||||
sizeBytes: item.sizeBytes
|
||||
}, action);
|
||||
@@ -2074,6 +2094,39 @@ export const AddDownloadsModal = () => {
|
||||
<span className="text-text-muted">KiB/s</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
|
||||
<label htmlFor="torrent-max-peers" className="text-text-muted">
|
||||
{t($ => $.addDownloads.torrentMaxPeers)}
|
||||
</label>
|
||||
<input
|
||||
id="torrent-max-peers"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
step={1}
|
||||
value={torrentMaxPeers}
|
||||
onChange={event => setTorrentMaxPeers(event.target.value)}
|
||||
placeholder="55"
|
||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||
aria-describedby="torrent-peer-options-hint"
|
||||
/>
|
||||
<label htmlFor="torrent-peer-speed-limit" className="text-text-muted">
|
||||
{t($ => $.addDownloads.torrentPeerSpeedLimit)}
|
||||
</label>
|
||||
<input
|
||||
id="torrent-peer-speed-limit"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={torrentPeerSpeedLimit}
|
||||
onChange={event => setTorrentPeerSpeedLimit(event.target.value)}
|
||||
placeholder="50K"
|
||||
className="app-control w-24 px-2 py-1 text-end font-mono"
|
||||
aria-describedby="torrent-peer-options-hint"
|
||||
/>
|
||||
<p id="torrent-peer-options-hint" className="col-span-2 text-[10px] text-text-muted">
|
||||
{t($ => $.addDownloads.torrentPeerOptionsHint)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
formatDownloadTotal,
|
||||
resolveDownloadSizeDisplay
|
||||
} from '../utils/downloadProgress';
|
||||
import { resolveDownloadConnections } from '../utils/downloads';
|
||||
import { normalizeSpeedLimitForBackend, resolveDownloadConnections } from '../utils/downloads';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
|
||||
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
||||
@@ -75,8 +75,11 @@ export const PropertiesModal = () => {
|
||||
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
|
||||
const [liveSpeedLimitValue, setLiveSpeedLimitValue] = useState('');
|
||||
const [liveTorrentUploadLimitValue, setLiveTorrentUploadLimitValue] = useState('');
|
||||
const [liveTorrentMaxPeersValue, setLiveTorrentMaxPeersValue] = useState('');
|
||||
const [liveTorrentPeerSpeedLimitValue, setLiveTorrentPeerSpeedLimitValue] = useState('');
|
||||
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
|
||||
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
|
||||
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
|
||||
|
||||
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -101,6 +104,7 @@ export const PropertiesModal = () => {
|
||||
actionRequestRef.current += 1;
|
||||
setIsLiveSpeedLimitPending(false);
|
||||
setIsLiveTorrentUploadLimitPending(false);
|
||||
setIsLiveTorrentPeerOptionsPending(false);
|
||||
}, [selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -160,6 +164,10 @@ export const PropertiesModal = () => {
|
||||
}
|
||||
setCookies(activeItem.cookies || '');
|
||||
setMirrors(activeItem.mirrors || '');
|
||||
setLiveTorrentMaxPeersValue(
|
||||
activeItem.torrentMaxPeers === undefined ? '' : String(activeItem.torrentMaxPeers)
|
||||
);
|
||||
setLiveTorrentPeerSpeedLimitValue(activeItem.torrentPeerSpeedLimit || '');
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
setSelectedPropertiesDownloadId(null);
|
||||
@@ -177,6 +185,13 @@ export const PropertiesModal = () => {
|
||||
setLiveTorrentUploadLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
|
||||
}, [item?.torrentUploadLimit, selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
setLiveTorrentMaxPeersValue(
|
||||
item?.torrentMaxPeers === undefined ? '' : String(item.torrentMaxPeers)
|
||||
);
|
||||
setLiveTorrentPeerSpeedLimitValue(item?.torrentPeerSpeedLimit || '');
|
||||
}, [item?.torrentMaxPeers, item?.torrentPeerSpeedLimit, selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPropertiesDownloadId || connectionsDirty) return;
|
||||
const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId);
|
||||
@@ -232,6 +247,23 @@ export const PropertiesModal = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedMaxPeers = liveTorrentMaxPeersValue.trim()
|
||||
? Number(liveTorrentMaxPeersValue)
|
||||
: undefined;
|
||||
if (
|
||||
item.isTorrent
|
||||
&& normalizedMaxPeers !== undefined
|
||||
&& (!Number.isInteger(normalizedMaxPeers) || normalizedMaxPeers < 0 || normalizedMaxPeers > 1000)
|
||||
) {
|
||||
setErrorMessage(t($ => $.properties.torrentMaxPeersInvalid));
|
||||
return;
|
||||
}
|
||||
const normalizedPeerSpeedLimit = normalizeSpeedLimitForBackend(liveTorrentPeerSpeedLimitValue);
|
||||
if (item.isTorrent && liveTorrentPeerSpeedLimitValue.trim() && !normalizedPeerSpeedLimit) {
|
||||
setErrorMessage(t($ => $.properties.torrentPeerSpeedLimitInvalid));
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Partial<DownloadItem> = {
|
||||
url,
|
||||
fileName,
|
||||
@@ -243,6 +275,12 @@ export const PropertiesModal = () => {
|
||||
checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined,
|
||||
cookies: cookies.trim() || undefined,
|
||||
mirrors: mirrors.trim() || undefined,
|
||||
...(item.isTorrent
|
||||
? {
|
||||
torrentMaxPeers: normalizedMaxPeers,
|
||||
torrentPeerSpeedLimit: normalizedPeerSpeedLimit || undefined,
|
||||
}
|
||||
: {}),
|
||||
...(connectionsDirty
|
||||
? { connections: resolveDownloadConnections(connections, perServerConnections) }
|
||||
: {}),
|
||||
@@ -362,11 +400,41 @@ export const PropertiesModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLiveTorrentPeerOptions = async () => {
|
||||
if (
|
||||
isLiveTorrentPeerOptionsPending
|
||||
|| !item.isTorrent
|
||||
|| !['downloading', 'seeding', 'retrying'].includes(item.status)
|
||||
) return;
|
||||
|
||||
setErrorMessage('');
|
||||
const requestId = ++actionRequestRef.current;
|
||||
setIsLiveTorrentPeerOptionsPending(true);
|
||||
try {
|
||||
await useDownloadStore.getState().setTorrentPeerOptions(
|
||||
item.id,
|
||||
liveTorrentMaxPeersValue,
|
||||
liveTorrentPeerSpeedLimitValue
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
|
||||
setErrorMessage(t($ => $.properties.liveTorrentPeerOptionsFailed, {
|
||||
detail: error instanceof Error ? error.message : String(error)
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) {
|
||||
setIsLiveTorrentPeerOptionsPending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const identityLocked = getIdentityLocked(item.status);
|
||||
const transferLocked = getTransferLocked(item.status);
|
||||
const liveSpeedLimitAvailable = !item.isMedia && ['downloading', 'retrying'].includes(item.status);
|
||||
const liveSpeedLimitUnavailable = item.isMedia && ['downloading', 'processing', 'retrying'].includes(item.status);
|
||||
const liveTorrentUploadLimitAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status);
|
||||
const liveTorrentPeerOptionsAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status);
|
||||
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
|
||||
const observedConnectionTotal = Math.max(
|
||||
1,
|
||||
@@ -606,6 +674,35 @@ export const PropertiesModal = () => {
|
||||
<div className="col-start-2 text-[11px] text-text-muted">
|
||||
{t($ => $.properties.savedPerDownload)}
|
||||
</div>
|
||||
{item.isTorrent && (
|
||||
<>
|
||||
<label className="text-xs text-text-muted text-right">{t($ => $.properties.torrentMaxPeers)}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
step={1}
|
||||
value={liveTorrentMaxPeersValue}
|
||||
onChange={event => setLiveTorrentMaxPeersValue(event.currentTarget.value)}
|
||||
placeholder="55"
|
||||
disabled={transferLocked}
|
||||
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
|
||||
/>
|
||||
<label className="text-xs text-text-muted text-right">{t($ => $.properties.torrentPeerSpeedLimit)}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={liveTorrentPeerSpeedLimitValue}
|
||||
onChange={event => setLiveTorrentPeerSpeedLimitValue(event.currentTarget.value)}
|
||||
placeholder="50K"
|
||||
disabled={transferLocked}
|
||||
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
|
||||
/>
|
||||
<div className="col-start-2 text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentPeerOptionsSavedHint)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && (
|
||||
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
|
||||
{liveSpeedLimitAvailable ? (
|
||||
@@ -692,6 +789,56 @@ export const PropertiesModal = () => {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{liveTorrentPeerOptionsAvailable && (
|
||||
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
|
||||
<div className="text-xs font-semibold text-text-primary">
|
||||
{t($ => $.properties.liveTorrentPeerOptions)}
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-2">
|
||||
<label htmlFor="live-torrent-max-peers" className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentMaxPeers)}
|
||||
</label>
|
||||
<input
|
||||
id="live-torrent-max-peers"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
step={1}
|
||||
value={liveTorrentMaxPeersValue}
|
||||
onChange={event => setLiveTorrentMaxPeersValue(event.currentTarget.value)}
|
||||
placeholder="55"
|
||||
disabled={isLiveTorrentPeerOptionsPending}
|
||||
aria-describedby="live-torrent-peer-options-hint"
|
||||
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
|
||||
/>
|
||||
<label htmlFor="live-torrent-peer-speed-limit" className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentPeerSpeedLimit)}
|
||||
</label>
|
||||
<input
|
||||
id="live-torrent-peer-speed-limit"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={liveTorrentPeerSpeedLimitValue}
|
||||
onChange={event => setLiveTorrentPeerSpeedLimitValue(event.currentTarget.value)}
|
||||
placeholder="50K"
|
||||
disabled={isLiveTorrentPeerOptionsPending}
|
||||
aria-describedby="live-torrent-peer-options-hint"
|
||||
className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleLiveTorrentPeerOptions()}
|
||||
disabled={isLiveTorrentPeerOptionsPending}
|
||||
className="app-button app-button-primary px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
{t($ => $.properties.liveTorrentPeerOptionsApply)}
|
||||
</button>
|
||||
<p id="live-torrent-peer-options-hint" className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.liveTorrentPeerOptionsHint)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -232,6 +232,15 @@ const common = {
|
||||
liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.',
|
||||
liveTorrentUploadLimitPlaceholder: 'e.g. 1024K',
|
||||
liveTorrentUploadLimitFailed: 'Could not update the live Torrent upload limit: {{detail}}',
|
||||
liveTorrentPeerOptions: 'Live Torrent peer controls',
|
||||
liveTorrentPeerOptionsApply: 'Apply peer controls',
|
||||
liveTorrentPeerOptionsHint: 'Changes apply without replacing the active Torrent. Leave blank to use Aria2 defaults.',
|
||||
torrentPeerOptionsSavedHint: 'Saved per Torrent. 0 peers means unlimited; blank uses Aria2 defaults.',
|
||||
torrentMaxPeers: 'Maximum Torrent peers',
|
||||
torrentPeerSpeedLimit: 'Peer speed threshold',
|
||||
torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Torrent peer speed threshold must be greater than zero',
|
||||
liveTorrentPeerOptionsFailed: 'Could not update live Torrent peer controls: {{detail}}',
|
||||
category: 'Category',
|
||||
lastTry: 'Last try',
|
||||
dateAdded: 'Date added',
|
||||
@@ -473,6 +482,11 @@ const common = {
|
||||
torrentSeedTimeInvalid: 'Torrent seed time must be greater than zero',
|
||||
torrentSeedRatioInvalid: 'Torrent seed ratio must be zero or greater',
|
||||
torrentUploadLimitInvalid: 'Torrent upload limit must be greater than zero',
|
||||
torrentMaxPeers: 'Maximum Torrent peers',
|
||||
torrentPeerSpeedLimit: 'Peer speed threshold',
|
||||
torrentPeerOptionsHint: 'Leave blank for Aria2 defaults (55 peers and 50K). 0 peers means unlimited.',
|
||||
torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Torrent peer speed threshold must be greater than zero',
|
||||
required: 'Required',
|
||||
free: 'Free',
|
||||
preview: 'Preview',
|
||||
|
||||
@@ -232,6 +232,15 @@ const fa = {
|
||||
liveTorrentUploadLimitHint: 'برای تورنتهای فعال و در حال سید اعمال میشود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
|
||||
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
|
||||
liveTorrentUploadLimitFailed: 'بهروزرسانی محدودیت زنده آپلود تورنت ممکن نیست: {{detail}}',
|
||||
liveTorrentPeerOptions: 'کنترل زنده همتاهای تورنت',
|
||||
liveTorrentPeerOptionsApply: 'اعمال کنترل همتا',
|
||||
liveTorrentPeerOptionsHint: 'بدون جایگزینی تورنت فعال اعمال میشود. برای استفاده از پیشفرض آریا۲ خالی بگذارید.',
|
||||
torrentPeerOptionsSavedHint: 'برای هر تورنت ذخیره میشود. صفر یعنی نامحدود؛ خالی یعنی پیشفرض آریا۲.',
|
||||
torrentMaxPeers: 'حداکثر همتاهای تورنت',
|
||||
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
|
||||
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
|
||||
torrentPeerSpeedLimitInvalid: 'آستانه سرعت همتای تورنت باید بیشتر از صفر باشد',
|
||||
liveTorrentPeerOptionsFailed: 'کنترل زنده همتاهای تورنت بهروزرسانی نشد: {{detail}}',
|
||||
category: 'دسته',
|
||||
lastTry: 'آخرین تلاش',
|
||||
dateAdded: 'تاریخ افزودن',
|
||||
@@ -473,6 +482,11 @@ const fa = {
|
||||
torrentSeedTimeInvalid: 'مدت سید تورنت باید بیشتر از صفر باشد',
|
||||
torrentSeedRatioInvalid: 'نسبت سید تورنت نمیتواند منفی باشد',
|
||||
torrentUploadLimitInvalid: 'محدودیت آپلود تورنت باید بیشتر از صفر باشد',
|
||||
torrentMaxPeers: 'حداکثر همتاهای تورنت',
|
||||
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
|
||||
torrentPeerOptionsHint: 'برای استفاده از پیشفرضهای آریا۲ خالی بگذارید (۵۵ همتا و 50K). صفر یعنی نامحدود.',
|
||||
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
|
||||
torrentPeerSpeedLimitInvalid: 'آستانه سرعت همتای تورنت باید بیشتر از صفر باشد',
|
||||
required: 'الزامی',
|
||||
free: 'فضای آزاد',
|
||||
preview: 'پیشنمایش',
|
||||
|
||||
@@ -232,6 +232,15 @@ const he = {
|
||||
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
|
||||
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
|
||||
liveTorrentUploadLimitFailed: 'לא ניתן לעדכן את הגבלת העלאת הטורנט בזמן אמת: {{detail}}',
|
||||
liveTorrentPeerOptions: 'בקרות עמיתי טורנט בזמן אמת',
|
||||
liveTorrentPeerOptionsApply: 'החל בקרות עמיתים',
|
||||
liveTorrentPeerOptionsHint: 'השינוי חל בלי להחליף את הטורנט הפעיל. השאר ריק כדי להשתמש בברירות המחדל של Aria2.',
|
||||
torrentPeerOptionsSavedHint: 'נשמר לכל טורנט. אפס עמיתים פירושו ללא הגבלה; ריק משתמש בברירות המחדל של Aria2.',
|
||||
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
|
||||
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
|
||||
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
|
||||
torrentPeerSpeedLimitInvalid: 'סף מהירות העמיתים חייב להיות גדול מאפס',
|
||||
liveTorrentPeerOptionsFailed: 'לא ניתן לעדכן את בקרות עמיתי הטורנט בזמן אמת: {{detail}}',
|
||||
category: 'קטגוריה',
|
||||
lastTry: 'ניסיון אחרון',
|
||||
dateAdded: 'תאריך הוספה',
|
||||
@@ -473,6 +482,11 @@ const he = {
|
||||
torrentSeedTimeInvalid: 'זמן שיתוף הטורנט חייב להיות גדול מאפס',
|
||||
torrentSeedRatioInvalid: 'יחס שיתוף הטורנט חייב להיות אפס או יותר',
|
||||
torrentUploadLimitInvalid: 'מגבלת העלאת הטורנט חייבת להיות גדולה מאפס',
|
||||
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
|
||||
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
|
||||
torrentPeerOptionsHint: 'השאר ריק כדי להשתמש בברירות המחדל של Aria2 (55 עמיתים ו-50K). אפס עמיתים פירושו ללא הגבלה.',
|
||||
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
|
||||
torrentPeerSpeedLimitInvalid: 'סף מהירות העמיתים חייב להיות גדול מאפס',
|
||||
required: 'נדרש',
|
||||
free: 'פנוי',
|
||||
preview: 'תצוגה מקדימה',
|
||||
|
||||
@@ -232,6 +232,15 @@ const ru = {
|
||||
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
|
||||
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
|
||||
liveTorrentUploadLimitFailed: 'Не удалось обновить текущий лимит отдачи торрента: {{detail}}',
|
||||
liveTorrentPeerOptions: 'Текущие настройки пиров торрента',
|
||||
liveTorrentPeerOptionsApply: 'Применить настройки пиров',
|
||||
liveTorrentPeerOptionsHint: 'Применяется без замены активного торрента. Оставьте пустым для параметров Aria2 по умолчанию.',
|
||||
torrentPeerOptionsSavedHint: 'Сохраняется для этого торрента. 0 пиров означает без ограничений; пустое поле использует настройки Aria2 по умолчанию.',
|
||||
torrentMaxPeers: 'Максимум пиров торрента',
|
||||
torrentPeerSpeedLimit: 'Порог скорости пиров',
|
||||
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Порог скорости пиров должен быть больше нуля',
|
||||
liveTorrentPeerOptionsFailed: 'Не удалось обновить текущие настройки пиров торрента: {{detail}}',
|
||||
category: 'Категория',
|
||||
lastTry: 'Последняя попытка',
|
||||
dateAdded: 'Дата добавления',
|
||||
@@ -473,6 +482,11 @@ const ru = {
|
||||
torrentSeedTimeInvalid: 'Время раздачи торрента должно быть больше нуля',
|
||||
torrentSeedRatioInvalid: 'Коэффициент раздачи торрента не может быть отрицательным',
|
||||
torrentUploadLimitInvalid: 'Лимит отдачи торрента должен быть больше нуля',
|
||||
torrentMaxPeers: 'Максимум пиров торрента',
|
||||
torrentPeerSpeedLimit: 'Порог скорости пиров',
|
||||
torrentPeerOptionsHint: 'Оставьте пустым для параметров Aria2 по умолчанию (55 пиров и 50K). 0 пиров означает без ограничений.',
|
||||
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Порог скорости пиров должен быть больше нуля',
|
||||
required: 'Требуется',
|
||||
free: 'Свободно',
|
||||
preview: 'Предпросмотр',
|
||||
|
||||
@@ -232,6 +232,15 @@ const uk = {
|
||||
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
|
||||
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
|
||||
liveTorrentUploadLimitFailed: 'Не вдалося оновити поточний ліміт віддачі торрента: {{detail}}',
|
||||
liveTorrentPeerOptions: 'Поточні налаштування пірів торрента',
|
||||
liveTorrentPeerOptionsApply: 'Застосувати налаштування пірів',
|
||||
liveTorrentPeerOptionsHint: 'Застосовується без заміни активного торрента. Залиште порожнім для стандартних параметрів Aria2.',
|
||||
torrentPeerOptionsSavedHint: 'Зберігається для цього торрента. 0 пірів означає без обмежень; порожнє поле використовує стандартні параметри Aria2.',
|
||||
torrentMaxPeers: 'Максимум пірів торрента',
|
||||
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
|
||||
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Поріг швидкості пірів має бути більшим за нуль',
|
||||
liveTorrentPeerOptionsFailed: 'Не вдалося оновити поточні налаштування пірів торрента: {{detail}}',
|
||||
category: 'Категорія',
|
||||
lastTry: 'Остання спроба',
|
||||
dateAdded: 'Дата додавання',
|
||||
@@ -473,6 +482,11 @@ const uk = {
|
||||
torrentSeedTimeInvalid: 'Час роздачі торрента має бути більшим за нуль',
|
||||
torrentSeedRatioInvalid: 'Коефіцієнт роздачі торрента не може бути від’ємним',
|
||||
torrentUploadLimitInvalid: 'Ліміт віддачі торрента має бути більшим за нуль',
|
||||
torrentMaxPeers: 'Максимум пірів торрента',
|
||||
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
|
||||
torrentPeerOptionsHint: 'Залиште порожнім для стандартних параметрів Aria2 (55 пірів і 50K). 0 пірів означає без обмежень.',
|
||||
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Поріг швидкості пірів має бути більшим за нуль',
|
||||
required: 'Обов\'язково',
|
||||
free: 'Вільно',
|
||||
preview: 'Попередній перегляд',
|
||||
|
||||
@@ -232,6 +232,15 @@ const zhCN = {
|
||||
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
|
||||
liveTorrentUploadLimitPlaceholder: '例如 1024K',
|
||||
liveTorrentUploadLimitFailed: '无法更新实时种子上传限速:{{detail}}',
|
||||
liveTorrentPeerOptions: 'Torrent 实时对等节点控制',
|
||||
liveTorrentPeerOptionsApply: '应用节点控制',
|
||||
liveTorrentPeerOptionsHint: '无需替换活动 Torrent 即可应用。留空以使用 Aria2 默认值。',
|
||||
torrentPeerOptionsSavedHint: '按 Torrent 保存。0 个节点表示不限制;留空使用 Aria2 默认值。',
|
||||
torrentMaxPeers: 'Torrent 最大对等节点数',
|
||||
torrentPeerSpeedLimit: '对等节点速度阈值',
|
||||
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
|
||||
torrentPeerSpeedLimitInvalid: '对等节点速度阈值必须大于零',
|
||||
liveTorrentPeerOptionsFailed: '无法更新 Torrent 实时对等节点控制:{{detail}}',
|
||||
category: '类别',
|
||||
lastTry: '上次尝试',
|
||||
dateAdded: '添加日期',
|
||||
@@ -473,6 +482,11 @@ const zhCN = {
|
||||
torrentSeedTimeInvalid: '做种时间必须大于零',
|
||||
torrentSeedRatioInvalid: '做种比率不能小于零',
|
||||
torrentUploadLimitInvalid: '种子上传限速必须大于零',
|
||||
torrentMaxPeers: 'Torrent 最大对等节点数',
|
||||
torrentPeerSpeedLimit: '对等节点速度阈值',
|
||||
torrentPeerOptionsHint: '留空以使用 Aria2 默认值(55 个节点和 50K)。0 个节点表示不限制。',
|
||||
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
|
||||
torrentPeerSpeedLimitInvalid: '对等节点速度阈值必须大于零',
|
||||
required: '必需',
|
||||
free: '可用空间',
|
||||
preview: '预览',
|
||||
|
||||
@@ -71,6 +71,10 @@ type CommandMap = {
|
||||
set_queue_concurrency_limits: { args: { limits: QueueConcurrencyConfig[] }; result: void };
|
||||
set_download_speed_limit: { args: { id: string; limit: string | null }; result: void };
|
||||
set_torrent_upload_limit: { args: { id: string; limit: string | null }; result: void };
|
||||
set_torrent_peer_options: {
|
||||
args: { id: string; max_peers: number | null; peer_speed_limit: string | null };
|
||||
result: void;
|
||||
};
|
||||
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
||||
request_automation_permission: { args: undefined; result: void };
|
||||
check_automation_permission: { args: undefined; result: void };
|
||||
|
||||
@@ -461,6 +461,91 @@ describe('useDownloadStore', () => {
|
||||
expect(useDownloadStore.getState().downloads[0].torrentUploadLimit).toBe('512K');
|
||||
});
|
||||
|
||||
it('updates active Torrent peer options and clears them to Aria2 defaults', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'live-torrent-peers',
|
||||
status: 'seeding',
|
||||
isMedia: false,
|
||||
isTorrent: true,
|
||||
torrentMaxPeers: 120,
|
||||
torrentPeerSpeedLimit: '512K'
|
||||
}] as any[]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
|
||||
|
||||
await useDownloadStore.getState().setTorrentPeerOptions('live-torrent-peers', '240', '2M');
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_peer_options', {
|
||||
id: 'live-torrent-peers',
|
||||
max_peers: 240,
|
||||
peer_speed_limit: '2M'
|
||||
});
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
torrentMaxPeers: 240,
|
||||
torrentPeerSpeedLimit: '2M'
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().setTorrentPeerOptions('live-torrent-peers', null, null);
|
||||
const peerOptionCalls = vi.mocked(ipc.invokeCommand).mock.calls
|
||||
.filter(([command]) => command === 'set_torrent_peer_options');
|
||||
expect(peerOptionCalls[peerOptionCalls.length - 1]).toEqual(['set_torrent_peer_options', {
|
||||
id: 'live-torrent-peers',
|
||||
max_peers: null,
|
||||
peer_speed_limit: null
|
||||
}]);
|
||||
expect(useDownloadStore.getState().downloads[0].torrentMaxPeers).toBeUndefined();
|
||||
expect(useDownloadStore.getState().downloads[0].torrentPeerSpeedLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects invalid or inactive live Torrent peer options', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{ id: 'ordinary-peers', status: 'downloading', isMedia: false, isTorrent: false },
|
||||
{ id: 'paused-peers', status: 'paused', isMedia: false, isTorrent: true }
|
||||
] as any[]
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().setTorrentPeerOptions('ordinary-peers', '100', '2M'))
|
||||
.rejects.toThrow('only for Torrent');
|
||||
await expect(useDownloadStore.getState().setTorrentPeerOptions('paused-peers', '100', '2M'))
|
||||
.rejects.toThrow('active Torrent');
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_torrent_peer_options', expect.anything());
|
||||
|
||||
useDownloadStore.setState({
|
||||
downloads: [{ id: 'invalid-peers', status: 'downloading', isMedia: false, isTorrent: true }] as any[]
|
||||
});
|
||||
await expect(useDownloadStore.getState().setTorrentPeerOptions('invalid-peers', '1001', '2M'))
|
||||
.rejects.toThrow('between 0 and 1000');
|
||||
await expect(useDownloadStore.getState().setTorrentPeerOptions('invalid-peers', '100', 'not-a-rate'))
|
||||
.rejects.toThrow('valid Torrent peer speed');
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_torrent_peer_options', expect.anything());
|
||||
});
|
||||
|
||||
it('keeps prior Torrent peer options when the backend rejects the update', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'live-torrent-peers-failure',
|
||||
status: 'downloading',
|
||||
isMedia: false,
|
||||
isTorrent: true,
|
||||
torrentMaxPeers: 120,
|
||||
torrentPeerSpeedLimit: '512K'
|
||||
}] as any[]
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
|
||||
if (command === 'set_torrent_peer_options') throw new Error('aria2 unavailable');
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().setTorrentPeerOptions('live-torrent-peers-failure', '240', '2M'))
|
||||
.rejects.toThrow('aria2 unavailable');
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
torrentMaxPeers: 120,
|
||||
torrentPeerSpeedLimit: '512K'
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects live speed changes for media and inactive downloads', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
@@ -610,6 +695,33 @@ describe('useDownloadStore', () => {
|
||||
.toEqual(['00000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001']);
|
||||
});
|
||||
|
||||
it('skips malformed persisted download records without blocking startup', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [
|
||||
'{not-json',
|
||||
JSON.stringify(null),
|
||||
JSON.stringify([]),
|
||||
JSON.stringify({
|
||||
id: 'valid-after-corruption',
|
||||
url: 'https://example.com/valid.bin',
|
||||
fileName: 'valid.bin',
|
||||
status: 'ready',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
})
|
||||
];
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
|
||||
expect(useDownloadStore.getState().downloads.map(download => download.id))
|
||||
.toEqual(['valid-after-corruption']);
|
||||
});
|
||||
|
||||
it('moves persisted paused rows behind runnable rows and assigns contiguous positions', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') {
|
||||
@@ -730,6 +842,23 @@ describe('useDownloadStore', () => {
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it('clears malformed persisted Torrent peer options', () => {
|
||||
const normalized = normalizePersistedDownloadProgress({
|
||||
id: 'malformed-torrent-options',
|
||||
url: 'magnet:?xt=urn:btih:bad',
|
||||
fileName: 'payload',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
isTorrent: true,
|
||||
torrentMaxPeers: 'not-a-number' as unknown as number,
|
||||
torrentPeerSpeedLimit: 0 as unknown as string
|
||||
});
|
||||
|
||||
expect(normalized.torrentMaxPeers).toBeUndefined();
|
||||
expect(normalized.torrentPeerSpeedLimit).toBeUndefined();
|
||||
});
|
||||
|
||||
it('normalizes proxy settings for download dispatch', async () => {
|
||||
expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080');
|
||||
expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000');
|
||||
|
||||
@@ -348,6 +348,8 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
||||
torrent_seed_time: item.torrentSeedTime,
|
||||
torrent_seed_ratio: item.torrentSeedRatio,
|
||||
torrent_upload_limit: item.torrentUploadLimit || undefined,
|
||||
torrent_max_peers: item.torrentMaxPeers,
|
||||
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
|
||||
lifecycle_generation: lifecycleGeneration.toString(),
|
||||
};
|
||||
|
||||
@@ -612,10 +614,30 @@ export const hasStaleTemporaryMediaEstimate = (
|
||||
return hasImpossibleNumericEstimate || hasImpossibleVisibleEstimate;
|
||||
};
|
||||
|
||||
export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem =>
|
||||
hasStaleTemporaryMediaEstimate(download)
|
||||
export const normalizePersistedDownloadProgress = (download: DownloadItem): DownloadItem => {
|
||||
const rawMaxPeers = download.torrentMaxPeers as unknown;
|
||||
const normalizedMaxPeers = typeof rawMaxPeers === 'number' &&
|
||||
Number.isInteger(rawMaxPeers) &&
|
||||
rawMaxPeers >= 0 &&
|
||||
rawMaxPeers <= 1000
|
||||
? rawMaxPeers
|
||||
: undefined;
|
||||
const rawPeerSpeedLimit = download.torrentPeerSpeedLimit as unknown;
|
||||
const normalizedPeerSpeedLimit = typeof rawPeerSpeedLimit === 'string'
|
||||
? normalizeSpeedLimitForBackend(rawPeerSpeedLimit) || undefined
|
||||
: undefined;
|
||||
const normalizedOptions = rawMaxPeers !== normalizedMaxPeers ||
|
||||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit
|
||||
? {
|
||||
...download,
|
||||
torrentMaxPeers: normalizedMaxPeers,
|
||||
torrentPeerSpeedLimit: normalizedPeerSpeedLimit
|
||||
}
|
||||
: download;
|
||||
|
||||
return hasStaleTemporaryMediaEstimate(normalizedOptions)
|
||||
? {
|
||||
...normalizedOptions,
|
||||
// The old lifecycle could persist yt-dlp's temporary HLS estimate as
|
||||
// both the numeric denominator and the visible size. Neither value is
|
||||
// recoverable after the fact, so remove the false claim on startup.
|
||||
@@ -623,7 +645,8 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
||||
totalBytes: undefined,
|
||||
totalIsEstimate: undefined
|
||||
}
|
||||
: download;
|
||||
: normalizedOptions;
|
||||
};
|
||||
|
||||
export type { DownloadStatus };
|
||||
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
|
||||
@@ -801,6 +824,11 @@ interface DownloadState {
|
||||
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
|
||||
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
|
||||
setTorrentUploadLimit: (id: string, limit: string | null) => Promise<void>;
|
||||
setTorrentPeerOptions: (
|
||||
id: string,
|
||||
maxPeers: string | null,
|
||||
peerSpeedLimit: string | null
|
||||
) => Promise<void>;
|
||||
setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise<void>;
|
||||
addQueue: (name: string) => boolean;
|
||||
renameQueue: (id: string, name: string) => boolean;
|
||||
@@ -1920,6 +1948,50 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
true,
|
||||
preemptDispatch
|
||||
),
|
||||
setTorrentPeerOptions: (id, maxPeers, peerSpeedLimit) => runDownloadLifecycleOperation(
|
||||
id,
|
||||
'torrent-peer-options',
|
||||
async () => {
|
||||
await waitForPendingStartupResume();
|
||||
const item = get().downloads.find(download => download.id === id);
|
||||
if (!item) throw new Error('Download no longer exists.');
|
||||
if (!item.isTorrent) {
|
||||
throw new Error('Live peer control is available only for Torrent downloads.');
|
||||
}
|
||||
if (!['downloading', 'seeding', 'retrying'].includes(item.status)) {
|
||||
throw new Error('Live peer control requires an active Torrent.');
|
||||
}
|
||||
|
||||
const trimmedMaxPeers = maxPeers?.trim() || '';
|
||||
const parsedMaxPeers = trimmedMaxPeers ? Number(trimmedMaxPeers) : null;
|
||||
if (
|
||||
parsedMaxPeers !== null
|
||||
&& (!Number.isInteger(parsedMaxPeers) || parsedMaxPeers < 0 || parsedMaxPeers > 1000)
|
||||
) {
|
||||
throw new Error('Torrent maximum peers must be an integer between 0 and 1000.');
|
||||
}
|
||||
const normalizedPeerSpeedLimit = peerSpeedLimit?.trim()
|
||||
? normalizeSpeedLimitForBackend(peerSpeedLimit)
|
||||
: null;
|
||||
if (peerSpeedLimit?.trim() && normalizedPeerSpeedLimit === null) {
|
||||
throw new Error('Enter a valid Torrent peer speed limit.');
|
||||
}
|
||||
|
||||
await invoke('set_torrent_peer_options', {
|
||||
id,
|
||||
max_peers: parsedMaxPeers,
|
||||
peer_speed_limit: normalizedPeerSpeedLimit
|
||||
});
|
||||
if (get().downloads.some(download => download.id === id)) {
|
||||
get().updateDownload(id, {
|
||||
torrentMaxPeers: parsedMaxPeers === null ? undefined : parsedMaxPeers,
|
||||
torrentPeerSpeedLimit: normalizedPeerSpeedLimit ?? undefined
|
||||
});
|
||||
}
|
||||
},
|
||||
true,
|
||||
preemptDispatch
|
||||
),
|
||||
setQueueConcurrency: (id, maxConcurrent) => {
|
||||
const operation = queueConfigurationQueue.then(async () => {
|
||||
if (
|
||||
@@ -2088,6 +2160,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
torrent_seed_time: item.torrentSeedTime,
|
||||
torrent_seed_ratio: item.torrentSeedRatio,
|
||||
torrent_upload_limit: item.torrentUploadLimit || undefined,
|
||||
torrent_max_peers: item.torrentMaxPeers,
|
||||
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
|
||||
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
|
||||
});
|
||||
}
|
||||
@@ -2212,9 +2286,18 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
const normalizedQueueState = normalizePersistedQueueState(persistedQueues);
|
||||
const queues = normalizedQueueState.queues;
|
||||
const knownQueueIds = new Set(queues.map(queue => queue.id));
|
||||
const downloads = (await invoke('db_get_all_downloads')).map(
|
||||
value => JSON.parse(value) as DownloadItem
|
||||
).map(download => {
|
||||
const downloads = (await invoke('db_get_all_downloads')).flatMap(value => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('persisted download is not an object');
|
||||
}
|
||||
return [parsed as DownloadItem];
|
||||
} catch {
|
||||
console.warn('Skipping malformed persisted download record during startup');
|
||||
return [];
|
||||
}
|
||||
}).map(download => {
|
||||
const persistedQueueId = download.queueId || MAIN_QUEUE_ID;
|
||||
const queueId = normalizedQueueState.queueIdRemap.get(persistedQueueId)
|
||||
|| (knownQueueIds.has(persistedQueueId) ? persistedQueueId : MAIN_QUEUE_ID);
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface AddDownloadDraftRow {
|
||||
torrentSeedTime?: number;
|
||||
torrentSeedRatio?: number;
|
||||
torrentUploadLimit?: string;
|
||||
torrentMaxPeers?: number;
|
||||
torrentPeerSpeedLimit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user