feat(torrents): add seeding lifecycle and upload controls

This commit is contained in:
NimBold
2026-08-01 21:27:49 +03:30
parent bb64c4cd52
commit dea6ad1974
26 changed files with 663 additions and 25 deletions
+13
View File
@@ -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<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_seed_time: Option<f64>,
#[serde(default)]
#[ts(optional)]
pub torrent_seed_ratio: Option<f64>,
#[serde(default)]
#[ts(optional)]
pub torrent_upload_limit: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
+75 -3
View File
@@ -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<i32>,
#[ts(optional)]
requested_connections: Option<i32>,
#[ts(optional)]
uploaded_bytes: Option<f64>,
#[ts(optional)]
upload_speed: Option<String>,
#[ts(optional)]
num_seeders: Option<i32>,
}
#[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<Instant>,
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::<AppState>();
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::<u64>().unwrap_or(0);
let completed = status_info.get("completedLength").and_then(|s| s.as_str()).unwrap_or("0").parse::<u64>().unwrap_or(0);
let speed_bytes = status_info.get("downloadSpeed").and_then(|s| s.as_str()).unwrap_or("0").parse::<f64>().unwrap_or(0.0);
let uploaded_bytes = status_info.get("uploadLength").and_then(|s| s.as_str()).and_then(|value| value.parse::<u64>().ok());
let upload_speed_bytes = status_info.get("uploadSpeed").and_then(|s| s.as_str()).and_then(|value| value.parse::<f64>().ok());
let num_seeders = status_info.get("numSeeders").and_then(|s| s.as_str()).and_then(|value| value.parse::<i32>().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")
+249
View File
@@ -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<String>,
pub torrent_file_indices: Option<Vec<u32>>,
pub torrent_seed_time: Option<f64>,
pub torrent_seed_ratio: Option<f64>,
pub torrent_upload_limit: Option<String>,
}
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
@@ -694,6 +701,14 @@ impl<R: tauri::Runtime> QueueManager<R> {
.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<String>) {
*self
.aria2_global_speed_limit
@@ -1629,6 +1644,18 @@ impl<R: tauri::Runtime> QueueManager<R> {
/// 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<R: tauri::Runtime> QueueManager<R> {
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<i32>) -> 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<i32>) -> 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<String, String> {
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<String, serde_json::Value>,
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<tauri::Wry>) -> 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<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_seed_time: Option<f64>,
#[serde(default)]
#[ts(optional)]
pub torrent_seed_ratio: Option<f64>,
#[serde(default)]
#[ts(optional)]
pub torrent_upload_limit: Option<String>,
#[serde(default)]
#[ts(optional)]
pub lifecycle_generation: Option<String>,
}
@@ -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<String, String> {
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();
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, };
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array<number>, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, };
+1 -1
View File
@@ -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, };
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, lifecycle_generation?: string, };
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array<number>, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, lifecycle_generation?: string, };
+99
View File
@@ -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 = () => {
</section>
)}
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
<section className="add-download-section relative overflow-hidden p-4">
<div className="add-download-section-title flex items-center gap-2 mb-3">
<HardDrive size={16} className="text-blue-500" /> {t($ => $.addDownloads.torrentSeeding)}
</div>
<div className="space-y-3 text-xs">
<label className="flex items-center gap-2 text-text-primary">
<input
type="checkbox"
checked={torrentSeedingEnabled}
onChange={event => setTorrentSeedingEnabled(event.target.checked)}
className="accent-blue-500"
/>
{t($ => $.addDownloads.seedAfterDownload)}
</label>
{torrentSeedingEnabled ? (
<div className="grid grid-cols-[1fr_auto] gap-2 items-center">
<label htmlFor="torrent-seed-time" className="text-text-muted">{t($ => $.addDownloads.seedTime)}</label>
<div className="flex items-center gap-1.5">
<input
id="torrent-seed-time"
type="number"
min={1}
step={1}
value={torrentSeedTime}
onChange={event => setTorrentSeedTime(event.target.value)}
className="app-control w-20 px-2 py-1 text-end font-mono"
/>
<span className="text-text-muted">{t($ => $.addDownloads.minutes)}</span>
</div>
<label htmlFor="torrent-seed-ratio" className="text-text-muted">{t($ => $.addDownloads.seedRatio)}</label>
<input
id="torrent-seed-ratio"
type="number"
min={0}
step={0.1}
value={torrentSeedRatio}
onChange={event => setTorrentSeedRatio(event.target.value)}
className="app-control w-20 px-2 py-1 text-end font-mono"
aria-describedby="torrent-seed-ratio-hint"
/>
<span id="torrent-seed-ratio-hint" className="col-span-2 text-[10px] text-text-muted">
{t($ => $.addDownloads.seedRatioHint)}
</span>
</div>
) : null}
<label className="flex items-center gap-2 text-text-primary">
<input
type="checkbox"
checked={torrentUploadLimitEnabled}
onChange={event => setTorrentUploadLimitEnabled(event.target.checked)}
className="accent-blue-500"
/>
{t($ => $.addDownloads.limitTorrentUpload)}
</label>
{torrentUploadLimitEnabled ? (
<div className="flex items-center gap-2">
<input
type="number"
min={1}
step={128}
value={torrentUploadLimit}
onChange={event => setTorrentUploadLimit(event.target.value)}
className="app-control w-24 px-2 py-1 text-end font-mono"
aria-label={t($ => $.addDownloads.torrentUploadLimit)}
/>
<span className="text-text-muted">KiB/s</span>
</div>
) : null}
</div>
</section>
)}
{/* Media Format (Dynamic) */}
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
<section className="add-download-section add-download-media-section relative overflow-hidden p-4">
+11 -3
View File
@@ -178,16 +178,20 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
};
}, [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<DownloadItemProps>(({
<div
className={`download-progress-fill ${
download.status === 'paused' ? 'paused' :
download.status === 'seeding' ? 'seeding' :
download.status === 'processing' ? 'processing' :
download.status === 'queued' || download.status === 'staged' ? 'queued' :
download.status === 'retrying' ? 'retrying' : ''
@@ -312,6 +317,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
}
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<DownloadItemProps>(({
</>
) : download.status === 'downloading' ? (
displayPercent
) : download.status === 'seeding' ? (
displayPercent
) : download.status === 'processing' ? (
downloadStatusLabel
) : (
+6 -1
View File
@@ -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; }
+13
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+9
View File
@@ -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;
+78
View File
@@ -125,6 +125,84 @@ describe('useDownloadProgressStore', () => {
release();
});
it('projects torrent seeding state and upload telemetry', async () => {
const handlers: Record<string, (event: any) => 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<string, (event: any) => 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<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
+16 -7
View File
@@ -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<DownloadItem> = {};
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]);
+9 -3
View File
@@ -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<DownloadState>((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<DownloadState>((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<DownloadState>((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(),
});
}
+3
View File
@@ -59,6 +59,9 @@ export interface AddDownloadDraftRow {
torrentInfoHash?: string;
torrentFiles?: TorrentFile[];
selectedTorrentFileIndices?: number[];
torrentSeedTime?: number;
torrentSeedRatio?: number;
torrentUploadLimit?: string;
}
/**
+4 -1
View File
@@ -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);
});
+2 -1
View File
@@ -11,6 +11,7 @@ const PAUSABLE_STATUSES: ReadonlySet<DownloadStatus> = 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';
+2
View File
@@ -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';
+1
View File
@@ -20,6 +20,7 @@ const isFreshDownloadStatus = (status: DownloadItem['status']): boolean =>
status === 'staged' ||
status === 'queued' ||
status === 'downloading' ||
status === 'seeding' ||
status === 'processing' ||
status === 'retrying';
+4 -2
View File
@@ -30,6 +30,7 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = 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'
]);
/**