From 9247c5cf9c6a40719f8f803151a3676f91bdd21f Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 1 Aug 2026 21:41:04 +0330 Subject: [PATCH] feat(torrents): add live upload limit control --- src-tauri/src/lib.rs | 14 ++- src-tauri/src/queue.rs | 104 ++++++++++++++++ src-tauri/tests/queue_manager.rs | 193 +++++++++++++++++++++++++++++ src/components/PropertiesModal.tsx | 81 ++++++++++++ src/i18n/catalogs/en.ts | 4 + src/i18n/catalogs/fa.ts | 4 + src/i18n/catalogs/he.ts | 4 + src/i18n/catalogs/ru.ts | 4 + src/i18n/catalogs/uk.ts | 4 + src/i18n/catalogs/zh-CN.ts | 4 + src/ipc.ts | 1 + src/store/useDownloadStore.test.ts | 65 ++++++++++ src/store/useDownloadStore.ts | 34 +++++ 13 files changed, 515 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ac41264..f581b51 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6288,6 +6288,18 @@ async fn set_download_speed_limit( .await } +#[tauri::command] +async fn set_torrent_upload_limit( + state: tauri::State<'_, AppState>, + id: String, + limit: Option, +) -> Result<(), String> { + state + .queue_manager + .set_aria2_torrent_upload_limit(&id, limit) + .await +} + pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option { let trimmed = limit.trim(); if trimmed.is_empty() { @@ -10611,7 +10623,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_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_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 984e4ff..71ee60d 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -258,6 +258,16 @@ pub trait SidecarSpawner: Send + Sync + 'static { Err("live aria2 speed limits are unavailable".to_string()) } + /// Change one active BitTorrent transfer's runtime upload cap without + /// replacing its GID or queue permit. + async fn set_torrent_upload_limit( + &self, + _gid: &str, + _limit: Option<&str>, + ) -> Result<(), String> { + Err("live torrent upload limits 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( @@ -797,6 +807,76 @@ impl QueueManager { Ok(()) } + /// Change an active Torrent's upload cap without replacing its GID or + /// queue permit. The control lock and post-RPC ownership check fence a + /// late response from a terminal or replaced lifecycle. + pub async fn set_aria2_torrent_upload_limit( + &self, + id: &str, + limit: Option, + ) -> Result<(), String> { + let normalized_limit = match limit.as_deref().map(str::trim) { + None | Some("") => None, + Some(raw) => Some( + crate::normalize_speed_limit_for_aria2(raw) + .ok_or_else(|| "invalid torrent upload limit".to_string())?, + ), + }; + 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_upload_limit(&gid, normalized_limit.as_deref()) + .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 upload limit".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_upload_limit = normalized_limit; + Ok(()) + } + /// Pop the next task, or None if empty. pub async fn pop_front(&self) -> Option { self.pending.lock().await.pop_front() @@ -3185,6 +3265,30 @@ impl SidecarSpawner for ProductionSpawner { } } + async fn set_torrent_upload_limit( + &self, + gid: &str, + limit: Option<&str>, + ) -> Result<(), String> { + let state = self.app_handle.state::(); + let limit = limit.unwrap_or("0"); + let result = crate::rpc_call( + state.aria2_port.load(std::sync::atomic::Ordering::Relaxed), + &state.aria2_secret, + "aria2.changeOption", + serde_json::json!([gid, {"max-upload-limit": 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, diff --git a/src-tauri/tests/queue_manager.rs b/src-tauri/tests/queue_manager.rs index a4ef29b..154930c 100644 --- a/src-tauri/tests/queue_manager.rs +++ b/src-tauri/tests/queue_manager.rs @@ -15,6 +15,11 @@ struct CountingSpawner { media_calls: AtomicUsize, speed_limit_calls: AtomicUsize, last_speed_limit: std::sync::Mutex>, + torrent_upload_limit_calls: AtomicUsize, + last_torrent_upload_limit: std::sync::Mutex>, + block_torrent_upload_limit: std::sync::atomic::AtomicBool, + torrent_upload_limit_started: tokio::sync::Notify, + torrent_upload_limit_release: tokio::sync::Notify, add_speed_limits: std::sync::Mutex>>, block_speed_limit: std::sync::atomic::AtomicBool, speed_limit_started: tokio::sync::Notify, @@ -188,6 +193,11 @@ impl CountingSpawner { media_calls: AtomicUsize::new(0), speed_limit_calls: AtomicUsize::new(0), last_speed_limit: std::sync::Mutex::new(None), + torrent_upload_limit_calls: AtomicUsize::new(0), + last_torrent_upload_limit: std::sync::Mutex::new(None), + 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(), add_speed_limits: std::sync::Mutex::new(Vec::new()), block_speed_limit: std::sync::atomic::AtomicBool::new(false), speed_limit_started: tokio::sync::Notify::new(), @@ -286,6 +296,22 @@ impl firelink_lib::queue::SidecarSpawner for CountingSpawner { } Ok(()) } + async fn set_torrent_upload_limit( + &self, + _gid: &str, + limit: Option<&str>, + ) -> Result<(), String> { + self.torrent_upload_limit_calls.fetch_add(1, Ordering::SeqCst); + *self.last_torrent_upload_limit.lock().unwrap() = limit.map(str::to_string); + if self + .block_torrent_upload_limit + .load(std::sync::atomic::Ordering::SeqCst) + { + self.torrent_upload_limit_started.notify_one(); + self.torrent_upload_limit_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(()) @@ -543,6 +569,173 @@ async fn live_aria2_speed_limit_does_not_update_payload_after_gid_replacement() dispatcher.abort(); } +#[tokio::test] +async fn live_torrent_upload_limit_updates_current_gid_and_payload() { + let (manager, spawner) = make_manager(1); + let manager = Arc::new(manager); + let mut task = aria2_task("torrent-upload-limit"); + 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-upload-limit").is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("aria2 dispatch should register a Torrent gid"); + + manager + .set_aria2_torrent_upload_limit( + "torrent-upload-limit", + Some("512K".to_string()), + ) + .await + .unwrap(); + assert_eq!( + spawner.torrent_upload_limit_calls.load(Ordering::SeqCst), + 1 + ); + assert_eq!( + spawner + .last_torrent_upload_limit + .lock() + .unwrap() + .as_deref(), + Some("512K") + ); + manager + .set_aria2_torrent_upload_limit("torrent-upload-limit", None) + .await + .unwrap(); + assert_eq!( + spawner.torrent_upload_limit_calls.load(Ordering::SeqCst), + 2 + ); + assert!(spawner + .last_torrent_upload_limit + .lock() + .unwrap() + .is_none()); + + manager.clear_aria2_retry_state("torrent-upload-limit").await; + manager.forget_aria2_gid("torrent-upload-limit").await; + manager.release_permit("torrent-upload-limit").await; + manager.release_registered_id("torrent-upload-limit").await; + dispatcher.abort(); +} + +#[tokio::test] +async fn live_torrent_upload_limit_rejects_non_torrents_and_invalid_values() { + let (manager, spawner) = make_manager(1); + + assert!(manager + .set_aria2_torrent_upload_limit("missing", Some("512K".to_string())) + .await + .is_err()); + + let manager = Arc::new(manager); + let task = aria2_task("ordinary-download"); + 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("ordinary-download").is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("aria2 dispatch should register a gid"); + + assert!(manager + .set_aria2_torrent_upload_limit("ordinary-download", Some("not-a-rate".to_string())) + .await + .is_err()); + assert!(manager + .set_aria2_torrent_upload_limit("ordinary-download", Some("512K".to_string())) + .await + .is_err()); + assert_eq!(spawner.torrent_upload_limit_calls.load(Ordering::SeqCst), 0); + + manager + .apply_completion( + "ordinary-download", + firelink_lib::queue::PendingOutcome::Complete, + ) + .await; + dispatcher.abort(); +} + +#[tokio::test] +async fn live_torrent_upload_limit_does_not_update_after_gid_replacement() { + let (manager, spawner) = make_manager(1); + let manager = Arc::new(manager); + let mut task = aria2_task("torrent-upload-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-upload-stale").is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("aria2 dispatch should register a Torrent gid"); + + spawner + .block_torrent_upload_limit + .store(true, std::sync::atomic::Ordering::SeqCst); + let started = spawner.torrent_upload_limit_started.notified(); + let setter = { + let manager = Arc::clone(&manager); + tokio::spawn(async move { + manager + .set_aria2_torrent_upload_limit( + "torrent-upload-stale", + Some("512K".to_string()), + ) + .await + }) + }; + timeout(Duration::from_secs(1), started) + .await + .expect("Torrent upload RPC should start"); + + manager + .remember_gid( + "torrent-upload-stale".to_string(), + "gid-replaced".to_string(), + ) + .await; + spawner.torrent_upload_limit_release.notify_one(); + assert!(setter.await.unwrap().is_err()); + + manager.clear_aria2_retry_state("torrent-upload-stale").await; + manager.forget_aria2_gid("torrent-upload-stale").await; + manager.release_permit("torrent-upload-stale").await; + manager.release_registered_id("torrent-upload-stale").await; + dispatcher.abort(); +} + #[tokio::test] async fn retry_readds_aria2_with_the_latest_live_speed_limit() { use firelink_lib::queue::PendingOutcome; diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index 7605d9f..296319c 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -74,7 +74,9 @@ export const PropertiesModal = () => { const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false); const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s const [liveSpeedLimitValue, setLiveSpeedLimitValue] = useState(''); + const [liveTorrentUploadLimitValue, setLiveTorrentUploadLimitValue] = useState(''); const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false); + const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false); const [loginMode, setLoginMode] = useState('matching'); const [username, setUsername] = useState(''); @@ -97,6 +99,8 @@ export const PropertiesModal = () => { // Invalidate native pickers and transfer-control results when the modal // switches items, closes, or reopens for the same download. actionRequestRef.current += 1; + setIsLiveSpeedLimitPending(false); + setIsLiveTorrentUploadLimitPending(false); }, [selectedPropertiesDownloadId]); useEffect(() => { @@ -168,6 +172,11 @@ export const PropertiesModal = () => { setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : ''); }, [item?.speedLimit, selectedPropertiesDownloadId]); + useEffect(() => { + const activeLimit = item?.torrentUploadLimit?.trim(); + setLiveTorrentUploadLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : ''); + }, [item?.torrentUploadLimit, selectedPropertiesDownloadId]); + useEffect(() => { if (!selectedPropertiesDownloadId || connectionsDirty) return; const activeItem = useDownloadStore.getState().downloads.find(d => d.id === selectedPropertiesDownloadId); @@ -321,10 +330,43 @@ export const PropertiesModal = () => { } }; + const handleLiveTorrentUploadLimit = async (limit: string | null) => { + if ( + isLiveTorrentUploadLimitPending + || !item.isTorrent + || !['downloading', 'seeding', 'retrying'].includes(item.status) + ) return; + + setErrorMessage(''); + const requestId = ++actionRequestRef.current; + setIsLiveTorrentUploadLimitPending(true); + try { + await useDownloadStore.getState().setTorrentUploadLimit(item.id, limit); + if ( + limit === null + && requestId === actionRequestRef.current + && useDownloadStore.getState().selectedPropertiesDownloadId === item.id + ) { + setLiveTorrentUploadLimitValue(''); + } + } catch (error) { + if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { + setErrorMessage(t($ => $.properties.liveTorrentUploadLimitFailed, { + detail: error instanceof Error ? error.message : String(error) + })); + } + } finally { + if (requestId === actionRequestRef.current && useDownloadStore.getState().selectedPropertiesDownloadId === item.id) { + setIsLiveTorrentUploadLimitPending(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 configuredConnections = resolveDownloadConnections(item.connections, perServerConnections); const observedConnectionTotal = Math.max( 1, @@ -611,6 +653,45 @@ export const PropertiesModal = () => { )} )} + {liveTorrentUploadLimitAvailable && ( +
+ +
+ setLiveTorrentUploadLimitValue(event.currentTarget.value)} + placeholder={t($ => $.properties.liveTorrentUploadLimitPlaceholder)} + disabled={isLiveTorrentUploadLimitPending} + aria-describedby="live-torrent-upload-limit-hint" + className="app-control w-32 px-2.5 py-1.5 text-xs font-mono disabled:opacity-50" + /> + + +
+

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

+
+ )} diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index ec7d02a..9c1addb 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -228,6 +228,10 @@ const common = { liveSpeedLimitClear: 'Clear', liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}', liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.', + liveTorrentUploadLimit: 'Live Torrent upload limit', + 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}}', category: 'Category', lastTry: 'Last try', dateAdded: 'Date added', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index e3334f5..818750d 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -228,6 +228,10 @@ const fa = { liveSpeedLimitClear: 'پاک کردن', liveSpeedLimitFailed: 'به‌روزرسانی سقف سرعت زنده ممکن نیست: {{detail}}', liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانه‌ای هنگام اجرا در دسترس نیست.', + liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت', + liveTorrentUploadLimitHint: 'برای تورنت‌های فعال و در حال سید اعمال می‌شود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.', + liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K', + liveTorrentUploadLimitFailed: 'به‌روزرسانی محدودیت زنده آپلود تورنت ممکن نیست: {{detail}}', category: 'دسته', lastTry: 'آخرین تلاش', dateAdded: 'تاریخ افزودن', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 217e82d..32f8edf 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -228,6 +228,10 @@ const he = { liveSpeedLimitClear: 'נקה', liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}', liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.', + liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת', + liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.', + liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K', + liveTorrentUploadLimitFailed: 'לא ניתן לעדכן את הגבלת העלאת הטורנט בזמן אמת: {{detail}}', category: 'קטגוריה', lastTry: 'ניסיון אחרון', dateAdded: 'תאריך הוספה', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 7ae2a6e..211857b 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -228,6 +228,10 @@ const ru = { liveSpeedLimitClear: 'Очистить', liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}', liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.', + liveTorrentUploadLimit: 'Текущий лимит отдачи торрента', + liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.', + liveTorrentUploadLimitPlaceholder: 'например, 1024K', + liveTorrentUploadLimitFailed: 'Не удалось обновить текущий лимит отдачи торрента: {{detail}}', category: 'Категория', lastTry: 'Последняя попытка', dateAdded: 'Дата добавления', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 05b8e4d..e93f466 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -228,6 +228,10 @@ const uk = { liveSpeedLimitClear: 'Очистити', liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}', liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.', + liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента', + liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.', + liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K', + liveTorrentUploadLimitFailed: 'Не вдалося оновити поточний ліміт віддачі торрента: {{detail}}', category: 'Категорія', lastTry: 'Остання спроба', dateAdded: 'Дата додавання', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index 09b50f3..a855336 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -228,6 +228,10 @@ const zhCN = { liveSpeedLimitClear: '清除', liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}', liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。', + liveTorrentUploadLimit: '实时种子上传限速', + liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。', + liveTorrentUploadLimitPlaceholder: '例如 1024K', + liveTorrentUploadLimitFailed: '无法更新实时种子上传限速:{{detail}}', category: '类别', lastTry: '上次尝试', dateAdded: '添加日期', diff --git a/src/ipc.ts b/src/ipc.ts index b9984ee..eba5b41 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -70,6 +70,7 @@ type CommandMap = { set_concurrent_limit: { args: { limit: number }; result: void }; 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_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 d8aa39c..f247db5 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -396,6 +396,71 @@ describe('useDownloadStore', () => { expect(useDownloadStore.getState().downloads[0].speedLimit).toBeUndefined(); }); + it('updates an active Torrent upload limit while seeding and clears it', async () => { + useDownloadStore.setState({ + downloads: [{ + id: 'live-torrent-upload', + status: 'seeding', + isMedia: false, + isTorrent: true, + torrentUploadLimit: '512K' + }] as any[] + }); + vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never); + + await useDownloadStore.getState().setTorrentUploadLimit('live-torrent-upload', '2M'); + + expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_upload_limit', { + id: 'live-torrent-upload', + limit: '2M' + }); + expect(useDownloadStore.getState().downloads[0].torrentUploadLimit).toBe('2M'); + + await useDownloadStore.getState().setTorrentUploadLimit('live-torrent-upload', null); + const uploadLimitCalls = vi.mocked(ipc.invokeCommand).mock.calls + .filter(([command]) => command === 'set_torrent_upload_limit'); + expect(uploadLimitCalls[uploadLimitCalls.length - 1]).toEqual(['set_torrent_upload_limit', { + id: 'live-torrent-upload', + limit: null + }]); + expect(useDownloadStore.getState().downloads[0].torrentUploadLimit).toBeUndefined(); + }); + + it('rejects live Torrent upload control for ordinary or inactive downloads', async () => { + useDownloadStore.setState({ + downloads: [ + { id: 'ordinary-upload', status: 'downloading', isMedia: false, isTorrent: false }, + { id: 'paused-upload', status: 'paused', isMedia: false, isTorrent: true } + ] as any[] + }); + + await expect(useDownloadStore.getState().setTorrentUploadLimit('ordinary-upload', '2M')) + .rejects.toThrow('only for Torrent'); + await expect(useDownloadStore.getState().setTorrentUploadLimit('paused-upload', '2M')) + .rejects.toThrow('active Torrent'); + expect(ipc.invokeCommand).not.toHaveBeenCalledWith('set_torrent_upload_limit', expect.anything()); + }); + + it('keeps the prior Torrent upload limit when the backend rejects the update', async () => { + useDownloadStore.setState({ + downloads: [{ + id: 'live-torrent-upload-failure', + status: 'downloading', + isMedia: false, + isTorrent: true, + torrentUploadLimit: '512K' + }] as any[] + }); + vi.mocked(ipc.invokeCommand).mockImplementation(async command => { + if (command === 'set_torrent_upload_limit') throw new Error('aria2 unavailable'); + return undefined; + }); + + await expect(useDownloadStore.getState().setTorrentUploadLimit('live-torrent-upload-failure', '2M')) + .rejects.toThrow('aria2 unavailable'); + expect(useDownloadStore.getState().downloads[0].torrentUploadLimit).toBe('512K'); + }); + it('rejects live speed changes for media and inactive downloads', async () => { useDownloadStore.setState({ downloads: [ diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 8fba23b..40d9aed 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -800,6 +800,7 @@ interface DownloadState { pauseAll: () => Promise; assignToQueue: (ids: string[], queueId: string) => Promise; setDownloadSpeedLimit: (id: string, limit: string | null) => Promise; + setTorrentUploadLimit: (id: string, limit: string | null) => Promise; setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise; addQueue: (name: string) => boolean; renameQueue: (id: string, name: string) => boolean; @@ -1886,6 +1887,39 @@ export const useDownloadStore = create((set, get) => { true, preemptDispatch ), + setTorrentUploadLimit: (id, limit) => runDownloadLifecycleOperation( + id, + 'torrent-upload-limit', + 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 upload control is available only for Torrent downloads.'); + } + if (!['downloading', 'seeding', 'retrying'].includes(item.status)) { + throw new Error('Live upload control requires an active Torrent.'); + } + + const trimmed = limit?.trim() || ''; + const normalizedLimit = trimmed + ? normalizeSpeedLimitForBackend(trimmed) + : null; + if (trimmed && normalizedLimit === null) { + throw new Error('Enter a valid Torrent upload limit.'); + } + + await invoke('set_torrent_upload_limit', { + id, + limit: normalizedLimit + }); + if (get().downloads.some(download => download.id === id)) { + get().updateDownload(id, { torrentUploadLimit: normalizedLimit ?? undefined }); + } + }, + true, + preemptDispatch + ), setQueueConcurrency: (id, maxConcurrent) => { const operation = queueConfigurationQueue.then(async () => { if (