feat(torrents): add live upload limit control

This commit is contained in:
NimBold
2026-08-01 21:41:04 +03:30
parent dea6ad1974
commit 9247c5cf9c
13 changed files with 515 additions and 1 deletions
+13 -1
View File
@@ -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<String>,
) -> Result<(), String> {
state
.queue_manager
.set_aria2_torrent_upload_limit(&id, limit)
.await
}
pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option<String> {
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,
+104
View File
@@ -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<R: tauri::Runtime> QueueManager<R> {
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<String>,
) -> 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<QueuedTask> {
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::<crate::AppState>();
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,
+193
View File
@@ -15,6 +15,11 @@ struct CountingSpawner {
media_calls: AtomicUsize,
speed_limit_calls: AtomicUsize,
last_speed_limit: std::sync::Mutex<Option<String>>,
torrent_upload_limit_calls: AtomicUsize,
last_torrent_upload_limit: std::sync::Mutex<Option<String>>,
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<Vec<Option<String>>>,
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;
+81
View File
@@ -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<LoginMode>('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 = () => {
)}
</div>
)}
{liveTorrentUploadLimitAvailable && (
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<label htmlFor="live-torrent-upload-limit" className="block text-xs font-semibold text-text-primary">
{t($ => $.properties.liveTorrentUploadLimit)}
</label>
<div className="flex flex-wrap items-center gap-2">
<input
id="live-torrent-upload-limit"
type="text"
inputMode="decimal"
value={liveTorrentUploadLimitValue}
onChange={event => 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"
/>
<button
type="button"
onClick={() => void handleLiveTorrentUploadLimit(liveTorrentUploadLimitValue)}
disabled={isLiveTorrentUploadLimitPending}
className="app-button app-button-primary px-3 text-xs disabled:opacity-50"
>
{t($ => $.properties.liveSpeedLimitApply)}
</button>
<button
type="button"
onClick={() => void handleLiveTorrentUploadLimit(null)}
disabled={isLiveTorrentUploadLimitPending || !liveTorrentUploadLimitValue}
className="app-button px-3 text-xs disabled:opacity-50"
>
{t($ => $.properties.liveSpeedLimitClear)}
</button>
</div>
<p id="live-torrent-upload-limit-hint" className="text-[11px] text-text-muted">
{t($ => $.properties.liveTorrentUploadLimitHint)}
</p>
</div>
)}
</div>
</section>
+4
View File
@@ -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',
+4
View File
@@ -228,6 +228,10 @@ const fa = {
liveSpeedLimitClear: 'پاک کردن',
liveSpeedLimitFailed: 'به‌روزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانه‌ای هنگام اجرا در دسترس نیست.',
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
liveTorrentUploadLimitHint: 'برای تورنت‌های فعال و در حال سید اعمال می‌شود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
liveTorrentUploadLimitFailed: 'به‌روزرسانی محدودیت زنده آپلود تورنت ممکن نیست: {{detail}}',
category: 'دسته',
lastTry: 'آخرین تلاش',
dateAdded: 'تاریخ افزودن',
+4
View File
@@ -228,6 +228,10 @@ const he = {
liveSpeedLimitClear: 'נקה',
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
liveTorrentUploadLimitFailed: 'לא ניתן לעדכן את הגבלת העלאת הטורנט בזמן אמת: {{detail}}',
category: 'קטגוריה',
lastTry: 'ניסיון אחרון',
dateAdded: 'תאריך הוספה',
+4
View File
@@ -228,6 +228,10 @@ const ru = {
liveSpeedLimitClear: 'Очистить',
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
liveTorrentUploadLimitFailed: 'Не удалось обновить текущий лимит отдачи торрента: {{detail}}',
category: 'Категория',
lastTry: 'Последняя попытка',
dateAdded: 'Дата добавления',
+4
View File
@@ -228,6 +228,10 @@ const uk = {
liveSpeedLimitClear: 'Очистити',
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
liveTorrentUploadLimitFailed: 'Не вдалося оновити поточний ліміт віддачі торрента: {{detail}}',
category: 'Категорія',
lastTry: 'Остання спроба',
dateAdded: 'Дата додавання',
+4
View File
@@ -228,6 +228,10 @@ const zhCN = {
liveSpeedLimitClear: '清除',
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
liveTorrentUploadLimit: '实时种子上传限速',
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
liveTorrentUploadLimitPlaceholder: '例如 1024K',
liveTorrentUploadLimitFailed: '无法更新实时种子上传限速:{{detail}}',
category: '类别',
lastTry: '上次尝试',
dateAdded: '添加日期',
+1
View File
@@ -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 };
+65
View File
@@ -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: [
+34
View File
@@ -800,6 +800,7 @@ interface DownloadState {
pauseAll: () => Promise<number>;
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
setTorrentUploadLimit: (id: string, limit: string | null) => Promise<void>;
setQueueConcurrency: (id: string, maxConcurrent: number | null) => Promise<void>;
addQueue: (name: string) => boolean;
renameQueue: (id: string, name: string) => boolean;
@@ -1886,6 +1887,39 @@ export const useDownloadStore = create<DownloadState>((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 (