From dea6ad19748c97271fc8e532e447078b48111737 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 1 Aug 2026 21:27:49 +0330 Subject: [PATCH] feat(torrents): add seeding lifecycle and upload controls --- src-tauri/src/ipc.rs | 13 ++ src-tauri/src/lib.rs | 78 +++++++- src-tauri/src/queue.rs | 249 ++++++++++++++++++++++++++ src/bindings/DownloadItem.ts | 2 +- src/bindings/DownloadProgressEvent.ts | 2 +- src/bindings/DownloadStatus.ts | 2 +- src/bindings/EnqueueItem.ts | 2 +- src/components/AddDownloadsModal.tsx | 99 ++++++++++ src/components/DownloadItem.tsx | 14 +- src/components/PropertiesModal.tsx | 7 +- src/i18n/catalogs/en.ts | 13 ++ src/i18n/catalogs/fa.ts | 13 ++ src/i18n/catalogs/he.ts | 13 ++ src/i18n/catalogs/ru.ts | 13 ++ src/i18n/catalogs/uk.ts | 13 ++ src/i18n/catalogs/zh-CN.ts | 13 ++ src/index.css | 9 + src/store/downloadStore.test.ts | 78 ++++++++ src/store/downloadStore.ts | 23 ++- src/store/useDownloadStore.ts | 12 +- src/utils/addDownloadMetadata.ts | 3 + src/utils/downloadActions.test.ts | 5 +- src/utils/downloadActions.ts | 3 +- src/utils/downloadProgress.ts | 2 + src/utils/downloadSummary.ts | 1 + src/utils/downloads.ts | 6 +- 26 files changed, 663 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 65d939a..6a0f7cf 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -26,6 +26,9 @@ pub enum DownloadStatus { /// Post-download media processing such as yt-dlp/ffmpeg merging or /// extraction. The queue permit is still held. Processing, + /// A BitTorrent download has all selected data and is still seeding. + /// The Aria2 GID and queue permit remain live until seeding ends. + Seeding, Paused, Completed, Failed, @@ -42,6 +45,7 @@ impl DownloadStatus { Self::Staged => "staged", Self::Downloading => "downloading", Self::Processing => "processing", + Self::Seeding => "seeding", Self::Paused => "paused", Self::Completed => "completed", Self::Failed => "failed", @@ -156,6 +160,15 @@ pub struct DownloadItem { #[serde(default)] #[ts(optional)] pub torrent_info_hash: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_seed_time: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_seed_ratio: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_upload_limit: Option, } #[derive(Clone, Debug, Serialize, Deserialize, TS)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a933ce4..ac41264 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1468,6 +1468,9 @@ fn emit_media_progress( total_is_estimate, active_connections: None, requested_connections: None, + uploaded_bytes: None, + upload_speed: None, + num_seeders: None, }, ); state.last_progress_at = now; @@ -3038,6 +3041,12 @@ pub struct DownloadProgressEvent { active_connections: Option, #[ts(optional)] requested_connections: Option, + #[ts(optional)] + uploaded_bytes: Option, + #[ts(optional)] + upload_speed: Option, + #[ts(optional)] + num_seeders: Option, } #[derive(Debug, Clone, Serialize, TS)] @@ -4208,6 +4217,9 @@ pub(crate) async fn start_media_download_internal( total_is_estimate: Some(false), active_connections: None, requested_connections: None, + uploaded_bytes: None, + upload_speed: None, + num_seeders: None, }); } let lower = line.to_lowercase(); @@ -4281,6 +4293,9 @@ pub(crate) async fn start_media_download_internal( total_is_estimate: Some(false), active_connections: None, requested_connections: None, + uploaded_bytes: None, + upload_speed: None, + num_seeders: None, }); } } @@ -5313,7 +5328,7 @@ async fn reconcile_aria2_downloads(app_handle: &tauri::AppHandle) { port, &secret, "aria2.tellStatus", - serde_json::json!([gid, ["status", "errorCode", "errorMessage"]]), + serde_json::json!([gid, ["status", "seeder", "errorCode", "errorMessage"]]), ) .await { @@ -5359,6 +5374,17 @@ async fn reconcile_aria2_downloads(app_handle: &tauri::AppHandle) { let status_name = status.get("status").and_then(|value| value.as_str()); let outcome = match status_name { Some("complete") => Some(crate::queue::PendingOutcome::Complete), + Some("active") + if status.get("seeder").is_some_and(|value| { + value.as_str() == Some("true") || value.as_bool() == Some(true) + }) + && state + .queue_manager + .aria2_torrent_seeding_requested(&id) + .await => + { + Some(crate::queue::PendingOutcome::Seeding) + } Some("error") | Some("removed") => { let error_code = status .get("errorCode") @@ -9462,6 +9488,7 @@ struct Aria2ConnectionObservation { last_refreshed_at: Option, peak_speed_bytes: f64, last_completed: u64, + seeder: bool, } struct Aria2ConnectionSample<'a> { @@ -10160,6 +10187,7 @@ pub fn run() { let state = app_handle_bg.state::(); let outcome = match method { "aria2.onDownloadComplete" => Some(crate::queue::PendingOutcome::Complete), + "aria2.onBtDownloadComplete" => Some(crate::queue::PendingOutcome::Seeding), "aria2.onDownloadError" => { let mut msg = event.get("error_message").and_then(|m| m.as_str()).unwrap_or("aria2 download error").to_string(); let aria2_port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed); @@ -10235,7 +10263,19 @@ pub fn run() { .collect(); observations.retain(|id, _| mapped_ids.contains(id)); missing_gid_recovery_at.retain(|id, _| mapped_ids.contains(id)); - let params = serde_json::json!([["gid", "status", "totalLength", "completedLength", "downloadSpeed", "connections", "errorMessage"]]); + let params = serde_json::json!([[ + "gid", + "status", + "totalLength", + "completedLength", + "downloadSpeed", + "uploadLength", + "uploadSpeed", + "numSeeders", + "seeder", + "connections", + "errorMessage" + ]]); if let Ok(active_list) = rpc_call(poll_port.load(std::sync::atomic::Ordering::Relaxed), &poll_secret, "aria2.tellActive", params).await { if let Some(active_arr) = active_list.as_array() { let mut seen_ids = HashSet::new(); @@ -10251,6 +10291,12 @@ pub fn run() { let total = status_info.get("totalLength").and_then(|s| s.as_str()).unwrap_or("0").parse::().unwrap_or(0); let completed = status_info.get("completedLength").and_then(|s| s.as_str()).unwrap_or("0").parse::().unwrap_or(0); let speed_bytes = status_info.get("downloadSpeed").and_then(|s| s.as_str()).unwrap_or("0").parse::().unwrap_or(0.0); + let uploaded_bytes = status_info.get("uploadLength").and_then(|s| s.as_str()).and_then(|value| value.parse::().ok()); + let upload_speed_bytes = status_info.get("uploadSpeed").and_then(|s| s.as_str()).and_then(|value| value.parse::().ok()); + let num_seeders = status_info.get("numSeeders").and_then(|s| s.as_str()).and_then(|value| value.parse::().ok()); + let is_seeder = status_info.get("seeder").is_some_and(|value| { + value.as_str() == Some("true") || value.as_bool() == Some(true) + }); let active_connections = aria2_active_connection_count(status_info); let requested_connections = poll_mgr @@ -10292,9 +10338,12 @@ pub fn run() { now, }, ); + let entering_seeding = is_seeder && !observation.seeder; + observation.seeder = is_seeder; let fraction = if total > 0 { completed as f64 / total as f64 } else { 0.0 }; let speed = crate::download::format_speed(speed_bytes); + let upload_speed = upload_speed_bytes.map(crate::download::format_speed); let eta = if speed_bytes > 0.0 && total > completed { crate::download::format_duration((total - completed) as f64 / speed_bytes) } else { @@ -10319,8 +10368,22 @@ pub fn run() { total_is_estimate: Some(false), active_connections: Some(active_connections), requested_connections: Some(requested_connections), + uploaded_bytes: uploaded_bytes.map(|value| value as f64), + upload_speed, + num_seeders, }); + if entering_seeding + && poll_mgr.aria2_torrent_seeding_requested(&id).await + { + poll_mgr + .handle_aria2_event( + &gid, + crate::queue::PendingOutcome::Seeding, + ) + .await; + } + if let Some(reason) = recovery_reason { log::warn!( "aria2 connection recovery [{}]: gid {} reason={} speed={}B/s active_connections={} requested_connections={}", @@ -10360,7 +10423,7 @@ pub fn run() { poll_port.load(std::sync::atomic::Ordering::Relaxed), &poll_secret, "aria2.tellStatus", - serde_json::json!([gid, ["status", "errorCode", "errorMessage"]]), + serde_json::json!([gid, ["status", "seeder", "errorCode", "errorMessage"]]), ) .await { @@ -10424,6 +10487,15 @@ pub fn run() { .unwrap_or(""); let outcome = match status_name { "complete" => Some(crate::queue::PendingOutcome::Complete), + "active" + if status.get("seeder").is_some_and(|value| { + value.as_str() == Some("true") + || value.as_bool() == Some(true) + }) + && poll_mgr.aria2_torrent_seeding_requested(&id).await => + { + Some(crate::queue::PendingOutcome::Seeding) + } "error" | "removed" => { let error_code = status .get("errorCode") diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index dcf595f..984e4ff 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -122,6 +122,10 @@ impl Drop for Aria2ControlGuard { #[derive(Debug, Clone)] pub enum PendingOutcome { Complete, + /// BitTorrent payload is complete, but Aria2 is still seeding. Keep the + /// GID, ownership record, and queue permit until the final complete + /// notification arrives. + Seeding, Error(String), } @@ -205,6 +209,9 @@ pub struct SpawnPayload { pub is_torrent: bool, pub torrent_path: Option, pub torrent_file_indices: Option>, + pub torrent_seed_time: Option, + pub torrent_seed_ratio: Option, + pub torrent_upload_limit: Option, } /// A sidecar spawner. In production this calls the real aria2/yt-dlp @@ -694,6 +701,14 @@ impl QueueManager { .map(clamp_download_connections) } + pub async fn aria2_torrent_seeding_requested(&self, id: &str) -> bool { + self.aria2_payloads + .lock() + .await + .get(id) + .is_some_and(torrent_seeding_requested) + } + pub fn set_aria2_global_speed_limit(&self, limit: Option) { *self .aria2_global_speed_limit @@ -1629,6 +1644,18 @@ impl QueueManager { /// and lets commands reconcile an Aria2 terminal status without releasing /// the lock first. pub(crate) async fn apply_completion_locked(&self, id: &str, outcome: PendingOutcome) { + let outcome = match outcome { + PendingOutcome::Seeding if self.aria2_torrent_seeding_requested(id).await => { + self.emit_state(id, DownloadStatus::Seeding); + return; + } + // `onBtDownloadComplete` means that Aria2 is still seeding. If + // Firelink has no seeding policy for this payload, reconcile it + // as terminal so a daemon-side default or stale event cannot + // strand the GID and permit. + PendingOutcome::Seeding => PendingOutcome::Complete, + other => other, + }; if let Some(gid) = self.aria2_gid_for_download(id) { if let Err(error) = self.reconcile_aria2_torrent_ownership(id, &gid).await { log::debug!( @@ -1671,6 +1698,7 @@ impl QueueManager { self.release_permit(id).await; self.emit_failed(id, error); } + PendingOutcome::Seeding => unreachable!("seeding outcomes are normalized before terminal cleanup"), } } @@ -2583,6 +2611,16 @@ fn automatic_retry_limit(max_tries: Option) -> usize { max_tries.unwrap_or(MAX_RETRIES as i32).max(0) as usize } +fn torrent_seeding_requested(payload: &SpawnPayload) -> bool { + payload.is_torrent + && (payload + .torrent_seed_time + .is_some_and(|minutes| minutes.is_finite() && minutes > 0.0) + || payload + .torrent_seed_ratio + .is_some_and(|ratio| ratio.is_finite() && ratio >= 0.0)) +} + fn aria2_attempt_limit(max_tries: Option) -> u32 { // Firelink owns the retry budget and performs the backoff/GID rotation. // Keep each aria2 GID to one attempt so `max_tries` is not multiplied by @@ -2871,6 +2909,53 @@ fn apply_aria2_connection_options( ); } +fn format_aria2_torrent_number(value: f64, field: &str) -> Result { + if !value.is_finite() || value < 0.0 { + return Err(format!("torrent {field} must be a finite non-negative number")); + } + Ok(value.to_string()) +} + +fn apply_aria2_torrent_options( + options: &mut serde_json::Map, + payload: &SpawnPayload, +) -> Result<(), String> { + if !payload.is_torrent { + return Ok(()); + } + + let seed_time = payload + .torrent_seed_time + .map(|value| format_aria2_torrent_number(value, "seed time")) + .transpose()?; + let seed_ratio = payload + .torrent_seed_ratio + .map(|value| format_aria2_torrent_number(value, "seed ratio")) + .transpose()?; + + // Aria2 treats seed-time=0 as an explicit disable. Omit it when a ratio + // policy exists so ratio-only and unlimited-ratio policies remain active. + // With no policy, keep the explicit zero to prevent daemon defaults from + // turning a normal Torrent download into an untracked seeding lifecycle. + if seed_ratio.is_none() || payload.torrent_seed_time.is_some_and(|value| value > 0.0) { + options.insert( + "seed-time".to_string(), + serde_json::json!(seed_time.unwrap_or_else(|| "0".to_string())), + ); + } + + if let Some(seed_ratio) = seed_ratio { + options.insert("seed-ratio".to_string(), serde_json::json!(seed_ratio)); + } + + if let Some(upload_limit) = payload.torrent_upload_limit.as_deref() { + let normalized = crate::normalize_speed_limit_for_aria2(upload_limit) + .ok_or_else(|| "torrent upload limit must be greater than zero".to_string())?; + options.insert("max-upload-limit".to_string(), serde_json::json!(normalized)); + } + Ok(()) +} + impl ProductionSpawner { pub fn new(app_handle: AppHandle) -> Self { Self { app_handle } @@ -2930,6 +3015,7 @@ impl SidecarSpawner for ProductionSpawner { } let conn = effective_aria2_connections(id, payload).await; apply_aria2_connection_options(&mut options, conn); + apply_aria2_torrent_options(&mut options, payload)?; let mt = aria2_attempt_limit(payload.max_tries); options.insert("max-tries".to_string(), serde_json::json!(mt.to_string())); options.insert("retry-wait".to_string(), serde_json::json!("2")); @@ -3390,6 +3476,15 @@ pub struct EnqueueItem { pub torrent_info_hash: Option, #[serde(default)] #[ts(optional)] + pub torrent_seed_time: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_seed_ratio: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_upload_limit: Option, + #[serde(default)] + #[ts(optional)] pub lifecycle_generation: Option, } @@ -3432,6 +3527,9 @@ impl EnqueueItem { is_torrent: self.is_torrent.unwrap_or(false), torrent_path: self.torrent_path, torrent_file_indices: self.torrent_file_indices, + torrent_seed_time: self.torrent_seed_time, + torrent_seed_ratio: self.torrent_seed_ratio, + torrent_upload_limit: self.torrent_upload_limit, }, } } @@ -3441,6 +3539,28 @@ impl EnqueueItem { mod tests { use super::*; + struct TestSpawner; + + #[async_trait::async_trait] + impl SidecarSpawner for TestSpawner { + async fn add_uri(&self, _id: &str, _payload: &SpawnPayload) -> Result { + Ok("test-gid".to_string()) + } + + async fn remove_uri(&self, _gid: &str) -> Result<(), String> { + Ok(()) + } + + async fn run_media( + &self, + _id: &str, + _payload: &SpawnPayload, + _lifecycle_generation: u64, + ) -> Result<(), String> { + Ok(()) + } + } + #[test] fn aria2_connection_options_enable_requested_ranges_for_small_release_assets() { let mut options = serde_json::Map::new(); @@ -3462,6 +3582,135 @@ mod tests { ); } + #[test] + fn torrent_options_disable_seeding_when_no_policy_is_saved() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + ..Default::default() + }; + + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + + assert_eq!(options.get("seed-time"), Some(&serde_json::json!("0"))); + assert!(!options.contains_key("max-upload-limit")); + } + + #[test] + fn torrent_options_preserve_seed_policy_and_upload_limit() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_seed_time: Some(30.0), + torrent_seed_ratio: Some(1.5), + torrent_upload_limit: Some("2 MiB/s".to_string()), + ..Default::default() + }; + + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + + assert_eq!(options.get("seed-time"), Some(&serde_json::json!("30"))); + assert_eq!(options.get("seed-ratio"), Some(&serde_json::json!("1.5"))); + assert_eq!( + options.get("max-upload-limit"), + Some(&serde_json::json!("2M")) + ); + assert!(torrent_seeding_requested(&payload)); + } + + #[test] + fn torrent_options_support_ratio_only_seeding_without_disabling_seed_time() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_seed_ratio: Some(1.5), + ..Default::default() + }; + + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + + assert!(!options.contains_key("seed-time")); + assert_eq!(options.get("seed-ratio"), Some(&serde_json::json!("1.5"))); + assert!(torrent_seeding_requested(&payload)); + } + + #[test] + fn torrent_options_reject_invalid_seed_values() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_seed_time: Some(f64::NAN), + ..Default::default() + }; + + let error = apply_aria2_torrent_options(&mut options, &payload).unwrap_err(); + assert!(error.contains("seed time")); + } + + #[tokio::test] + async fn seeding_outcome_keeps_torrent_ownership_and_permit_live() { + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let manager = QueueManager::test_new( + app.handle().clone(), + 1, + Arc::new(TestSpawner), + ); + let payload = SpawnPayload { + is_torrent: true, + torrent_seed_time: Some(30.0), + ..Default::default() + }; + + assert!(manager.ensure_aria2_permit("torrent").await); + manager + .aria2_payloads + .lock() + .await + .insert("torrent".to_string(), payload); + manager + .remember_gid("torrent".to_string(), "test-gid".to_string()) + .await; + manager + .apply_completion("torrent", PendingOutcome::Seeding) + .await; + + assert_eq!(manager.aria2_gid_for_download("torrent").as_deref(), Some("test-gid")); + assert_eq!(manager.available_permits(), 0); + } + + #[tokio::test] + async fn unexpected_seeding_outcome_without_policy_is_reconciled_as_complete() { + let app = tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app"); + let manager = QueueManager::test_new( + app.handle().clone(), + 1, + Arc::new(TestSpawner), + ); + + assert!(manager.ensure_aria2_permit("torrent").await); + manager + .aria2_payloads + .lock() + .await + .insert( + "torrent".to_string(), + SpawnPayload { + is_torrent: true, + ..Default::default() + }, + ); + manager + .apply_completion("torrent", PendingOutcome::Seeding) + .await; + + assert_eq!(manager.aria2_gid_for_download("torrent"), None); + assert_eq!(manager.available_permits(), 1); + } + #[test] fn aria2_connection_options_never_emit_zero_connections() { let mut options = serde_json::Map::new(); diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index 233f8b7..061482d 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, }; +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, }; diff --git a/src/bindings/DownloadProgressEvent.ts b/src/bindings/DownloadProgressEvent.ts index a497cec..c633c14 100644 --- a/src/bindings/DownloadProgressEvent.ts +++ b/src/bindings/DownloadProgressEvent.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 DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, }; +export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, uploaded_bytes?: number, upload_speed?: string, num_seeders?: number, }; diff --git a/src/bindings/DownloadStatus.ts b/src/bindings/DownloadStatus.ts index 3bc465a..e2f7a06 100644 --- a/src/bindings/DownloadStatus.ts +++ b/src/bindings/DownloadStatus.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 DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "paused" | "completed" | "failed" | "queued" | "retrying"; +export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "paused" | "completed" | "failed" | "queued" | "retrying"; diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index 7e5df74..9dcbf34 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, 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, lifecycle_generation?: string, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 4bdaabb..19ee908 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -225,6 +225,11 @@ export const AddDownloadsModal = () => { const [connections, setConnections] = useState(perServerConnections); const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false); const [speedLimit, setSpeedLimit] = useState('1024'); + const [torrentSeedingEnabled, setTorrentSeedingEnabled] = useState(false); + const [torrentSeedTime, setTorrentSeedTime] = useState('60'); + const [torrentSeedRatio, setTorrentSeedRatio] = useState('1.0'); + const [torrentUploadLimitEnabled, setTorrentUploadLimitEnabled] = useState(false); + const [torrentUploadLimit, setTorrentUploadLimit] = useState('1024'); const [freeSpace, setFreeSpace] = useState('Unknown'); const freeSpaceRequestRef = useRef(0); @@ -358,6 +363,11 @@ export const AddDownloadsModal = () => { setFreeSpace('Unknown'); setSpeedLimitEnabled(false); setSpeedLimit('1024'); + setTorrentSeedingEnabled(false); + setTorrentSeedTime('60'); + setTorrentSeedRatio('1.0'); + setTorrentUploadLimitEnabled(false); + setTorrentUploadLimit('1024'); setUseAuth(false); setUsername(''); setPassword(''); @@ -931,6 +941,19 @@ export const AddDownloadsModal = () => { addToast({ message: t($ => $.addDownloads.speedInvalid), variant: 'error', isActionable: true }); return; } + const hasSelectedTorrent = parsedItems.some(item => item.selected !== false && item.isTorrent); + if (hasSelectedTorrent && torrentSeedingEnabled && (!Number.isFinite(Number(torrentSeedTime)) || Number(torrentSeedTime) <= 0)) { + addToast({ message: t($ => $.addDownloads.torrentSeedTimeInvalid), variant: 'error', isActionable: true }); + return; + } + if (hasSelectedTorrent && torrentSeedingEnabled && (!Number.isFinite(Number(torrentSeedRatio)) || Number(torrentSeedRatio) < 0)) { + addToast({ message: t($ => $.addDownloads.torrentSeedRatioInvalid), variant: 'error', isActionable: true }); + return; + } + if (hasSelectedTorrent && torrentUploadLimitEnabled && (!Number.isFinite(Number(torrentUploadLimit)) || Number(torrentUploadLimit) <= 0)) { + addToast({ message: t($ => $.addDownloads.torrentUploadLimitInvalid), variant: 'error', isActionable: true }); + return; + } if (saveInDedicatedFolder && !sanitizeBatchFolderName(dedicatedFolderName)) { addToast({ message: t($ => $.addDownloads.dedicatedFolderNameRequired), @@ -1402,6 +1425,9 @@ export const AddDownloadsModal = () => { torrentPath, torrentInfoHash: item.torrentInfoHash, torrentFileIndices: item.selectedTorrentFileIndices, + torrentSeedTime: item.isTorrent && torrentSeedingEnabled ? Number(torrentSeedTime) : undefined, + torrentSeedRatio: item.isTorrent && torrentSeedingEnabled ? Number(torrentSeedRatio) : undefined, + torrentUploadLimit: item.isTorrent && torrentUploadLimitEnabled ? `${torrentUploadLimit}K` : undefined, size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined), sizeBytes: item.sizeBytes }, action); @@ -1979,6 +2005,79 @@ export const AddDownloadsModal = () => { )} + {selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && ( +
+
+ {t($ => $.addDownloads.torrentSeeding)} +
+
+ + {torrentSeedingEnabled ? ( +
+ +
+ setTorrentSeedTime(event.target.value)} + className="app-control w-20 px-2 py-1 text-end font-mono" + /> + {t($ => $.addDownloads.minutes)} +
+ + setTorrentSeedRatio(event.target.value)} + className="app-control w-20 px-2 py-1 text-end font-mono" + aria-describedby="torrent-seed-ratio-hint" + /> + + {t($ => $.addDownloads.seedRatioHint)} + +
+ ) : null} + + {torrentUploadLimitEnabled ? ( +
+ setTorrentUploadLimit(event.target.value)} + className="app-control w-24 px-2 py-1 text-end font-mono" + aria-label={t($ => $.addDownloads.torrentUploadLimit)} + /> + KiB/s +
+ ) : null} +
+
+ )} + {/* Media Format (Dynamic) */} {selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 72d2a66..2504d0c 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -178,16 +178,20 @@ export const DownloadItem = React.memo(({ }; }, [isActionVisible, updateActionPosition]); - const displayFraction = download.status === 'downloading' + const displayFraction = download.status === 'downloading' || download.status === 'seeding' ? liveProgress?.fraction ?? download.fraction ?? 0 : download.fraction ?? 0; const displayPercent = `${(displayFraction * 100).toFixed(0)}%`; - const displaySpeed = download.status === 'downloading' + const displaySpeed = download.status === 'seeding' + ? liveProgress?.upload_speed ?? '-' + : download.status === 'downloading' ? liveProgress?.speed ?? download.speed : download.status === 'processing' ? t($ => $.downloads.values.processing) : '-'; - const displayEta = download.status === 'downloading' + const displayEta = download.status === 'seeding' + ? '-' + : download.status === 'downloading' ? liveProgress?.eta ?? download.eta : download.status === 'processing' ? t($ => $.downloads.values.muxing) @@ -291,6 +295,7 @@ export const DownloadItem = React.memo(({
(({ } className={`download-status flex items-center gap-1.5 ${ download.status === 'paused' ? 'download-status-paused' : + download.status === 'seeding' ? 'download-status-seeding' : download.status === 'failed' ? 'download-status-failed' : download.status === 'processing' ? 'download-status-processing' : download.status === 'downloading' ? 'download-status-downloading' : @@ -328,6 +334,8 @@ export const DownloadItem = React.memo(({ ) : download.status === 'downloading' ? ( displayPercent + ) : download.status === 'seeding' ? ( + displayPercent ) : download.status === 'processing' ? ( downloadStatusLabel ) : ( diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index bc3b48d..7605d9f 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -333,6 +333,7 @@ export const PropertiesModal = () => { const observedActiveConnections = liveProgress?.active_connections; const connectionTelemetryActive = item.status === 'downloading' || item.status === 'processing' || + item.status === 'seeding' || item.status === 'retrying'; const connectionStatus = (() => { if (!connectionTelemetryActive) return String(configuredConnections); @@ -363,9 +364,13 @@ export const PropertiesModal = () => { : liveProgress?.fraction ?? item.fraction ?? 0; const displayedSpeed = item.status === 'completed' ? '-' + : item.status === 'seeding' + ? liveProgress?.upload_speed ?? '-' : liveProgress?.speed ?? item.speed ?? '-'; const displayedEta = item.status === 'completed' ? '-' + : item.status === 'seeding' + ? '-' : liveProgress?.eta ?? item.eta ?? '-'; const sizeDisplay = resolveDownloadSizeDisplay({ downloadedBytes: liveProgress?.downloaded_bytes ?? item.downloadedBytes, @@ -400,7 +405,7 @@ export const PropertiesModal = () => { let statusColor = 'text-text-secondary'; let StatusIcon = Info; if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; } - else if (item.status === 'downloading' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; } + else if (item.status === 'downloading' || item.status === 'seeding' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; } else if (item.status === 'processing') { statusColor = 'text-sky-500'; StatusIcon = Play; } else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; } else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; } diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 2da502e..ec7d02a 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -89,6 +89,7 @@ const common = { queued: 'Queued', downloading: 'Downloading', processing: 'Processing', + seeding: 'Seeding', paused: 'Paused', completed: 'Completed', failed: 'Failed', @@ -457,6 +458,17 @@ const common = { torrentFiles: 'Torrent files', chooseTorrentFiles: 'Add .torrent files', torrentMetadataPending: 'Aria2 will resolve the magnet metadata when the transfer starts.', + torrentSeeding: 'Torrent seeding', + seedAfterDownload: 'Seed after download completes', + seedTime: 'Seed time', + minutes: 'minutes', + seedRatio: 'Seed ratio', + seedRatioHint: '0 means time-only seeding; otherwise seeding stops at the first limit reached.', + limitTorrentUpload: 'Limit torrent upload', + torrentUploadLimit: 'Torrent upload limit', + 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', required: 'Required', free: 'Free', preview: 'Preview', @@ -817,6 +829,7 @@ const common = { active: '{{count}} active', queued: '{{count}} queued', done: '{{count}} done', + seeding: 'Seeding', }, } as const; diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 9647f5a..e3334f5 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -89,6 +89,7 @@ const fa = { queued: 'در صف', downloading: 'در حال دانلود', processing: 'در حال پردازش', + seeding: 'در حال اشتراک‌گذاری', paused: 'متوقف‌شده', completed: 'تکمیل‌شده', failed: 'ناموفق', @@ -457,6 +458,17 @@ const fa = { torrentFiles: 'فایل‌های تورنت', chooseTorrentFiles: 'افزودن فایل‌های .torrent', torrentMetadataPending: 'آریا۲ هنگام شروع انتقال، متادیتای مگنت را دریافت می‌کند.', + torrentSeeding: 'اشتراک‌گذاری تورنت', + seedAfterDownload: 'پس از پایان دانلود سید شود', + seedTime: 'مدت سید', + minutes: 'دقیقه', + seedRatio: 'نسبت سید', + seedRatioHint: '۰ یعنی فقط مدت زمان تعیین‌شده ملاک است؛ در غیر این صورت با رسیدن به اولین حد متوقف می‌شود.', + limitTorrentUpload: 'محدود کردن آپلود تورنت', + torrentUploadLimit: 'محدودیت آپلود تورنت', + torrentSeedTimeInvalid: 'مدت سید تورنت باید بیشتر از صفر باشد', + torrentSeedRatioInvalid: 'نسبت سید تورنت نمی‌تواند منفی باشد', + torrentUploadLimitInvalid: 'محدودیت آپلود تورنت باید بیشتر از صفر باشد', required: 'الزامی', free: 'فضای آزاد', preview: 'پیش‌نمایش', @@ -817,6 +829,7 @@ const fa = { active: '{{count}} فعال', queued: '{{count}} در صف', done: '{{count}} تکمیل‌شده', + seeding: 'در حال اشتراک‌گذاری', }, } as const; diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index ca6c8e2..217e82d 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -89,6 +89,7 @@ const he = { queued: 'בתור', downloading: 'מוריד', processing: 'מעבד', + seeding: 'משתף', paused: 'מושהה', completed: 'הושלם', failed: 'נכשל', @@ -457,6 +458,17 @@ const he = { torrentFiles: 'קובצי טורנט', chooseTorrentFiles: 'הוספת קובצי ‎.torrent', torrentMetadataPending: 'Aria2 יאתר את נתוני המגנט כשההעברה תתחיל.', + torrentSeeding: 'שיתוף טורנט', + seedAfterDownload: 'לשתף לאחר סיום ההורדה', + seedTime: 'זמן שיתוף', + minutes: 'דקות', + seedRatio: 'יחס שיתוף', + seedRatioHint: '0 פירושו שיתוף לפי זמן בלבד; אחרת השיתוף ייפסק בהגעה למגבלה הראשונה.', + limitTorrentUpload: 'הגבלת העלאת טורנט', + torrentUploadLimit: 'מגבלת העלאת טורנט', + torrentSeedTimeInvalid: 'זמן שיתוף הטורנט חייב להיות גדול מאפס', + torrentSeedRatioInvalid: 'יחס שיתוף הטורנט חייב להיות אפס או יותר', + torrentUploadLimitInvalid: 'מגבלת העלאת הטורנט חייבת להיות גדולה מאפס', required: 'נדרש', free: 'פנוי', preview: 'תצוגה מקדימה', @@ -817,6 +829,7 @@ const he = { active: '{{count}} פעילים', queued: '{{count}} בתור', done: '{{count}} הושלמו', + seeding: 'משתף', }, } as const; diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index e04c09a..7ae2a6e 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -89,6 +89,7 @@ const ru = { queued: 'В очереди', downloading: 'Загрузка', processing: 'Обработка', + seeding: 'Раздача', paused: 'Приостановлено', completed: 'Завершено', failed: 'Ошибка', @@ -457,6 +458,17 @@ const ru = { torrentFiles: 'Торрент-файлы', chooseTorrentFiles: 'Добавить файлы .torrent', torrentMetadataPending: 'Aria2 получит метаданные магнита при запуске передачи.', + torrentSeeding: 'Раздача торрента', + seedAfterDownload: 'Раздавать после завершения загрузки', + seedTime: 'Время раздачи', + minutes: 'минут', + seedRatio: 'Коэффициент раздачи', + seedRatioHint: '0 означает раздачу только по времени; иначе раздача остановится при достижении первого ограничения.', + limitTorrentUpload: 'Ограничить отдачу торрента', + torrentUploadLimit: 'Лимит отдачи торрента', + torrentSeedTimeInvalid: 'Время раздачи торрента должно быть больше нуля', + torrentSeedRatioInvalid: 'Коэффициент раздачи торрента не может быть отрицательным', + torrentUploadLimitInvalid: 'Лимит отдачи торрента должен быть больше нуля', required: 'Требуется', free: 'Свободно', preview: 'Предпросмотр', @@ -817,6 +829,7 @@ const ru = { active: '{{count}} активных', queued: '{{count}} в очереди', done: '{{count}} завершено', + seeding: 'Раздача', }, } as const; diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index d1a74a3..05b8e4d 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -89,6 +89,7 @@ const uk = { queued: 'У черзі', downloading: 'Завантаження', processing: 'Обробка', + seeding: 'Роздача', paused: 'Призупинено', completed: 'Завершено', failed: 'Помилка', @@ -457,6 +458,17 @@ const uk = { torrentFiles: 'Торрент-файли', chooseTorrentFiles: 'Додати файли .torrent', torrentMetadataPending: 'Aria2 отримає метадані магнітного посилання після початку передачі.', + torrentSeeding: 'Роздача торрента', + seedAfterDownload: 'Роздавати після завершення завантаження', + seedTime: 'Час роздачі', + minutes: 'хвилин', + seedRatio: 'Коефіцієнт роздачі', + seedRatioHint: '0 означає роздачу лише за часом; інакше роздача зупиниться після досягнення першого обмеження.', + limitTorrentUpload: 'Обмежити віддачу торрента', + torrentUploadLimit: 'Ліміт віддачі торрента', + torrentSeedTimeInvalid: 'Час роздачі торрента має бути більшим за нуль', + torrentSeedRatioInvalid: 'Коефіцієнт роздачі торрента не може бути від’ємним', + torrentUploadLimitInvalid: 'Ліміт віддачі торрента має бути більшим за нуль', required: 'Обов\'язково', free: 'Вільно', preview: 'Попередній перегляд', @@ -817,6 +829,7 @@ const uk = { active: '{{count}} активних', queued: '{{count}} в черзі', done: '{{count}} завершено', + seeding: 'Роздача', }, } as const; diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index 368a20b..09b50f3 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -89,6 +89,7 @@ const zhCN = { queued: '已排队', downloading: '下载中', processing: '处理中', + seeding: '做种中', paused: '已暂停', completed: '已完成', failed: '失败', @@ -457,6 +458,17 @@ const zhCN = { torrentFiles: '种子文件', chooseTorrentFiles: '添加 .torrent 文件', torrentMetadataPending: '传输开始时,Aria2 将解析磁力链接元数据。', + torrentSeeding: 'BT 做种', + seedAfterDownload: '下载完成后继续做种', + seedTime: '做种时间', + minutes: '分钟', + seedRatio: '做种比率', + seedRatioHint: '0 表示仅按时间做种;否则达到第一个限制时停止做种。', + limitTorrentUpload: '限制种子上传', + torrentUploadLimit: '种子上传限速', + torrentSeedTimeInvalid: '做种时间必须大于零', + torrentSeedRatioInvalid: '做种比率不能小于零', + torrentUploadLimitInvalid: '种子上传限速必须大于零', required: '必需', free: '可用空间', preview: '预览', @@ -817,6 +829,7 @@ const zhCN = { active: '{{count}} 个进行中', queued: '{{count}} 个排队', done: '{{count}} 个完成', + seeding: '做种', }, } as const; diff --git a/src/index.css b/src/index.css index a10bdd5..05f00bb 100644 --- a/src/index.css +++ b/src/index.css @@ -3239,6 +3239,10 @@ html[dir="rtl"] .download-context-menu-chevron { background: hsl(199 89% 48%); } + .download-progress-fill.seeding { + background: hsl(262 83% 58%); + } + .download-progress-fill.queued { background: hsl(var(--status-queued)); } @@ -3281,6 +3285,11 @@ html[dir="rtl"] .download-context-menu-chevron { font-weight: 600; } + .download-status-seeding { + color: hsl(262 83% 58%); + font-weight: 600; + } + .download-status-queued { color: hsl(var(--status-queued)); font-weight: 600; diff --git a/src/store/downloadStore.test.ts b/src/store/downloadStore.test.ts index 7121ce0..b4016cf 100644 --- a/src/store/downloadStore.test.ts +++ b/src/store/downloadStore.test.ts @@ -125,6 +125,84 @@ describe('useDownloadProgressStore', () => { release(); }); + it('projects torrent seeding state and upload telemetry', async () => { + const handlers: Record void> = {}; + vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { + handlers[event] = handler as (event: any) => void; + return Promise.resolve(vi.fn()); + }); + useDownloadStore.setState({ + downloads: [{ + id: 'torrent-seeding', + url: 'magnet:?xt=urn:btih:test', + fileName: 'ubuntu.iso', + status: 'downloading', + category: 'Other', + dateAdded: '' + }] + }); + + const release = await initDownloadListener(); + handlers['download-state']({ payload: { + id: 'torrent-seeding', + status: 'seeding' + } }); + handlers['download-progress']({ payload: { + id: 'torrent-seeding', + fraction: 1, + speed: '0 B/s', + eta: '-', + size: '2 GB', + size_is_final: false, + uploaded_bytes: 1048576, + upload_speed: '512 KiB/s', + num_seeders: 4, + active_connections: 6, + requested_connections: 8 + } }); + + expect(useDownloadStore.getState().downloads[0]).toMatchObject({ + status: 'seeding', + fraction: 1, + speed: '512 KiB/s', + eta: '-' + }); + expect(useDownloadProgressStore.getState().progressMap['torrent-seeding']) + .toMatchObject({ uploaded_bytes: 1048576, upload_speed: '512 KiB/s', num_seeders: 4 }); + release(); + }); + + it('does not regress a seeding row from a delayed active state event', async () => { + const handlers: Record void> = {}; + vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { + handlers[event] = handler as (event: any) => void; + return Promise.resolve(vi.fn()); + }); + useDownloadStore.setState({ + downloads: [{ + id: 'torrent-seeding-race', + url: 'magnet:?xt=urn:btih:test', + fileName: 'ubuntu.iso', + status: 'seeding', + category: 'Other', + dateAdded: '' + }] + }); + + const release = await initDownloadListener(); + handlers['download-state']({ payload: { + id: 'torrent-seeding-race', + status: 'downloading' + } }); + handlers['download-state']({ payload: { + id: 'torrent-seeding-race', + status: 'queued' + } }); + + expect(useDownloadStore.getState().downloads[0].status).toBe('seeding'); + release(); + }); + it('clears progress when events arrive after a download row was removed', async () => { const handlers: Record void> = {}; vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => { diff --git a/src/store/downloadStore.ts b/src/store/downloadStore.ts index 3c9c97b..b7d0f20 100644 --- a/src/store/downloadStore.ts +++ b/src/store/downloadStore.ts @@ -45,17 +45,19 @@ const startDownloadListeners = async () => { // A sidecar can flush one last progress chunk after a pause, failure, // completion, or lifecycle reset. Do not let that stale chunk repopulate // the live progress map or overwrite a later lifecycle's first frame. - if (!['downloading', 'processing'].includes(current.status)) { + if (!['downloading', 'processing', 'seeding'].includes(current.status)) { useDownloadProgressStore.getState().clearDownloadProgress(payload.id); return; } useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload); const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final)); const updates: Partial = {}; - if (current.status === 'downloading' || current.status === 'processing') { + if (current.status === 'downloading' || current.status === 'processing' || current.status === 'seeding') { updates.fraction = payload.fraction; - updates.speed = payload.speed; - updates.eta = payload.eta; + updates.speed = current.status === 'seeding' + ? payload.upload_speed ?? '-' + : payload.speed; + updates.eta = current.status === 'seeding' ? '-' : payload.eta; } if (shouldUpdateSize && current.size !== payload.size) { updates.size = payload.size!; @@ -119,7 +121,7 @@ const startDownloadListeners = async () => { return; } if (status === 'downloading' || status === 'processing' || - status === 'completed' || status === 'failed') { + status === 'seeding' || status === 'completed' || status === 'failed') { clearDownloadControlIntent(payload.id, 'resume'); } if (status === 'paused') { @@ -142,6 +144,13 @@ const startDownloadListeners = async () => { status !== 'failed') { return; } + if (current.status === 'seeding' && + status !== 'seeding' && + status !== 'paused' && + status !== 'completed' && + status !== 'failed') { + return; + } const progress = useDownloadProgressStore.getState().progressMap[payload.id]; if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) { @@ -179,7 +188,7 @@ const startDownloadListeners = async () => { } mainStore.updateDownload(payload.id, updates); - if (status === 'completed' || status === 'failed' || status === 'paused') { + if (status === 'completed' || status === 'failed' || status === 'paused' || status === 'seeding') { useDownloadStore.setState(state => ({ pendingOrder: state.pendingOrder.filter(id => id !== payload.id) })); @@ -189,7 +198,7 @@ const startDownloadListeners = async () => { : { pendingOrder: [...state.pendingOrder, payload.id] }); } - if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'retrying') { + if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying') { mainStore.registerBackendIds([payload.id]); } else if (status === 'completed' || status === 'failed') { mainStore.unregisterBackendIds([payload.id]); diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 7cfffbf..8fba23b 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -345,6 +345,9 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): torrent_path: item.torrentPath || undefined, torrent_file_indices: item.torrentFileIndices || undefined, torrent_info_hash: item.torrentInfoHash || undefined, + torrent_seed_time: item.torrentSeedTime, + torrent_seed_ratio: item.torrentSeedRatio, + torrent_upload_limit: item.torrentUploadLimit || undefined, lifecycle_generation: lifecycleGeneration.toString(), }; @@ -817,7 +820,7 @@ export const useDownloadStore = create((set, get) => { ? updates : { ...updates, fileName: canonicalizeDownloadFileName(updates.fileName) }; - if (item.status === 'downloading' || item.status === 'processing' || item.status === 'retrying') { + if (item.status === 'downloading' || item.status === 'processing' || item.status === 'seeding' || item.status === 'retrying') { throw new Error(i18n.t($ => $.downloadTable.transferActive)); } @@ -1411,8 +1414,8 @@ export const useDownloadStore = create((set, get) => { if (updates.status && ['completed', 'failed', 'paused'].includes(updates.status)) { info(`Download ${id} status changed to ${updates.status}`); syncSystemIntegrations(); - } else if (updates.status === 'downloading') { - info(`Download ${id} status changed to downloading`); + } else if (updates.status === 'downloading' || updates.status === 'seeding') { + info(`Download ${id} status changed to ${updates.status}`); syncSystemIntegrations(); } }, @@ -2048,6 +2051,9 @@ export const useDownloadStore = create((set, get) => { torrent_path: item.torrentPath || undefined, torrent_file_indices: item.torrentFileIndices || undefined, torrent_info_hash: item.torrentInfoHash || undefined, + torrent_seed_time: item.torrentSeedTime, + torrent_seed_ratio: item.torrentSeedRatio, + torrent_upload_limit: item.torrentUploadLimit || undefined, lifecycle_generation: currentDownloadLifecycle(item.id).toString(), }); } diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index d1c3b63..fb09045 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -59,6 +59,9 @@ export interface AddDownloadDraftRow { torrentInfoHash?: string; torrentFiles?: TorrentFile[]; selectedTorrentFileIndices?: number[]; + torrentSeedTime?: number; + torrentSeedRatio?: number; + torrentUploadLimit?: string; } /** diff --git a/src/utils/downloadActions.test.ts b/src/utils/downloadActions.test.ts index 78a7150..049d192 100644 --- a/src/utils/downloadActions.test.ts +++ b/src/utils/downloadActions.test.ts @@ -20,7 +20,7 @@ describe('download action policy', () => { expect(canStartDownload(status)).toBe(true); expect(canPauseDownload(status)).toBe(false); } - for (const status of ['staged', 'queued', 'downloading', 'processing', 'retrying'] as const) { + for (const status of ['staged', 'queued', 'downloading', 'seeding', 'processing', 'retrying'] as const) { expect(canPauseDownload(status)).toBe(true); } for (const status of ['queued', 'downloading', 'processing', 'retrying'] as const) { @@ -33,12 +33,14 @@ describe('download action policy', () => { expect(canRedownload('failed')).toBe(true); expect(canRedownload('paused')).toBe(true); expect(canRedownload('downloading')).toBe(false); + expect(canRedownload('seeding')).toBe(false); }); it('only exposes pause or resume for the details-view toggle', () => { expect(getPauseResumeAction('queued')).toBe('pause'); expect(getPauseResumeAction('downloading')).toBe('pause'); expect(getPauseResumeAction('processing')).toBe('pause'); + expect(getPauseResumeAction('seeding')).toBe('pause'); expect(getPauseResumeAction('retrying')).toBe('pause'); expect(getPauseResumeAction('paused')).toBe('resume'); @@ -53,6 +55,7 @@ describe('download action policy', () => { expect(startActionLabel('failed')).toBe('Start'); expect(startActionLabel('paused')).toBe('Resume'); expect(isTransferLocked('processing')).toBe(true); + expect(isTransferLocked('seeding')).toBe(true); expect(isIdentityLocked('completed')).toBe(true); expect(isTransferLocked('completed')).toBe(false); }); diff --git a/src/utils/downloadActions.ts b/src/utils/downloadActions.ts index 5b0d486..00b2b86 100644 --- a/src/utils/downloadActions.ts +++ b/src/utils/downloadActions.ts @@ -11,6 +11,7 @@ const PAUSABLE_STATUSES: ReadonlySet = new Set([ 'staged', 'queued', 'downloading', + 'seeding', 'processing', 'retrying', ]); @@ -63,7 +64,7 @@ export const startActionLabel = (status: DownloadStatus): 'Start' | 'Resume' => status === 'ready' || status === 'staged' || status === 'failed' ? 'Start' : 'Resume'; export const isTransferLocked = (status: DownloadStatus): boolean => - status === 'downloading' || status === 'processing' || status === 'retrying'; + status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying'; export const isIdentityLocked = (status: DownloadStatus): boolean => isTransferLocked(status) || status === 'completed'; diff --git a/src/utils/downloadProgress.ts b/src/utils/downloadProgress.ts index b3f47c3..a3415cd 100644 --- a/src/utils/downloadProgress.ts +++ b/src/utils/downloadProgress.ts @@ -73,6 +73,8 @@ export const downloadProgressColorClass = (status: string): string => { return 'download-status-failed'; case 'processing': return 'download-status-processing'; + case 'seeding': + return 'download-status-seeding'; case 'queued': case 'staged': return 'download-status-queued'; diff --git a/src/utils/downloadSummary.ts b/src/utils/downloadSummary.ts index d487f9d..b9940c5 100644 --- a/src/utils/downloadSummary.ts +++ b/src/utils/downloadSummary.ts @@ -20,6 +20,7 @@ const isFreshDownloadStatus = (status: DownloadItem['status']): boolean => status === 'staged' || status === 'queued' || status === 'downloading' || + status === 'seeding' || status === 'processing' || status === 'retrying'; diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index b5090f4..bc9358a 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -30,6 +30,7 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet = new Set([ 'queued', 'downloading', 'processing', + 'seeding', 'retrying', ]); @@ -38,7 +39,7 @@ export const isActiveDownloadStatus = (status: DownloadStatus): boolean => /** Transfer states that consume a worker/permit. Queued is intentionally excluded. */ export const isTransferActiveStatus = (status: DownloadStatus): boolean => - status === 'downloading' || status === 'processing' || status === 'retrying'; + status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'retrying'; export const DOWNLOAD_CONNECTIONS_MIN = 1; export const DOWNLOAD_CONNECTIONS_MAX = 16; @@ -255,7 +256,8 @@ export const isMediaUrl = (rawUrl: string): boolean => { */ const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const; const VOLATILE_PROGRESS_STATUSES = new Set([ - 'downloading' + 'downloading', + 'seeding' ]); /**