From ab6c3c372e8c1d3d5e58e7cf1d3fd86ba2fb9f1f Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 1 Aug 2026 22:27:32 +0330 Subject: [PATCH] feat(torrents): add live peer controls --- src-tauri/src/ipc.rs | 6 + src-tauri/src/lib.rs | 15 +- src-tauri/src/queue.rs | 196 ++++++++++++++++++++++ src-tauri/tests/queue_manager.rs | 242 +++++++++++++++++++++++++++ src/bindings/DownloadItem.ts | 2 +- src/bindings/EnqueueItem.ts | 2 +- src/components/AddDownloadsModal.tsx | 55 +++++- src/components/PropertiesModal.tsx | 149 ++++++++++++++++- src/i18n/catalogs/en.ts | 14 ++ src/i18n/catalogs/fa.ts | 14 ++ src/i18n/catalogs/he.ts | 14 ++ src/i18n/catalogs/ru.ts | 14 ++ src/i18n/catalogs/uk.ts | 14 ++ src/i18n/catalogs/zh-CN.ts | 14 ++ src/ipc.ts | 4 + src/store/useDownloadStore.test.ts | 129 ++++++++++++++ src/store/useDownloadStore.ts | 95 ++++++++++- src/utils/addDownloadMetadata.ts | 2 + 18 files changed, 970 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index a700537..4f50627 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -185,6 +185,12 @@ pub struct DownloadItem { #[serde(default)] #[ts(optional)] pub torrent_upload_limit: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_max_peers: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_peer_speed_limit: Option, } #[derive(Clone, Debug, Serialize, Deserialize, TS)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4d5255f..61b81e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, + peer_speed_limit: Option, +) -> 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 { 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, diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 71ee60d..8fc5f2c 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -212,6 +212,8 @@ pub struct SpawnPayload { pub torrent_seed_time: Option, pub torrent_seed_ratio: Option, pub torrent_upload_limit: Option, + pub torrent_max_peers: Option, + pub torrent_peer_speed_limit: Option, } /// 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 QueueManager { 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, + peer_speed_limit: Option, + ) -> 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 { 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, @@ -2996,6 +3084,27 @@ fn format_aria2_torrent_number(value: f64, field: &str) -> Result) -> Result, 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, 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, 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::(); + 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, #[serde(default)] #[ts(optional)] + pub torrent_max_peers: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_peer_speed_limit: Option, + #[serde(default)] + #[ts(optional)] pub lifecycle_generation: Option, } @@ -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() diff --git a/src-tauri/tests/queue_manager.rs b/src-tauri/tests/queue_manager.rs index 154930c..c566759 100644 --- a/src-tauri/tests/queue_manager.rs +++ b/src-tauri/tests/queue_manager.rs @@ -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>, + 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>>, + add_peer_options: std::sync::Mutex, Option)>>, 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); diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index 061482d..b010af6 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -2,4 +2,4 @@ import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadStatus } from "./DownloadStatus"; -export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, 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, 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, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, }; diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index 9dcbf34..075f7e9 100644 --- a/src/bindings/EnqueueItem.ts +++ b/src/bindings/EnqueueItem.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type 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, 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, 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, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 19ee908..e7b097d 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -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 = () => { KiB/s ) : null} +
+ + 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" + /> + + 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" + /> +

+ {t($ => $.addDownloads.torrentPeerOptionsHint)} +

+
)} diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index 296319c..ab212f3 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -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('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 = { 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 = () => {
{t($ => $.properties.savedPerDownload)}
+ {item.isTorrent && ( + <> + + 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" + /> + + 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" + /> +
+ {t($ => $.properties.torrentPeerOptionsSavedHint)} +
+ + )} {(liveSpeedLimitAvailable || liveSpeedLimitUnavailable) && (
{liveSpeedLimitAvailable ? ( @@ -692,6 +789,56 @@ export const PropertiesModal = () => {

)} + {liveTorrentPeerOptionsAvailable && ( +
+
+ {t($ => $.properties.liveTorrentPeerOptions)} +
+
+ + 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" + /> + + 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" + /> +
+ +

+ {t($ => $.properties.liveTorrentPeerOptionsHint)} +

+
+ )} diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 4d99fd3..a4e65e6 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -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', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 657c767..4ac38d0 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -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: 'پیش‌نمایش', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index a79e5d6..a560c7e 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -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: 'תצוגה מקדימה', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index ebc7c4c..bc6a3ee 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -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: 'Предпросмотр', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 58c3a95..69dd835 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -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: 'Попередній перегляд', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index 35c9076..1a62ef1 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -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: '预览', diff --git a/src/ipc.ts b/src/ipc.ts index eba5b41..71408e8 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -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 }; diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index f247db5..d511e6b 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -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'); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 40d9aed..c7937b1 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -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; setDownloadSpeedLimit: (id: string, limit: string | null) => Promise; setTorrentUploadLimit: (id: string, limit: string | null) => Promise; + setTorrentPeerOptions: ( + id: string, + maxPeers: string | null, + peerSpeedLimit: string | null + ) => Promise; setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise; addQueue: (name: string) => boolean; renameQueue: (id: string, name: string) => boolean; @@ -1920,6 +1948,50 @@ export const useDownloadStore = create((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((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((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); diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index fb09045..2440512 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -62,6 +62,8 @@ export interface AddDownloadDraftRow { torrentSeedTime?: number; torrentSeedRatio?: number; torrentUploadLimit?: string; + torrentMaxPeers?: number; + torrentPeerSpeedLimit?: string; } /**