feat(torrent): complete lifecycle controls

This commit is contained in:
NimBold
2026-08-03 21:47:02 +03:30
parent 819a48bd4b
commit 55a905df14
32 changed files with 2390 additions and 136 deletions
+70
View File
@@ -46,6 +46,14 @@ fn default_torrent_max_concurrent_seeds() -> u32 {
crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
}
fn default_torrent_ipv6_enabled() -> bool {
true
}
fn default_aria2_disk_cache() -> String {
crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string()
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "lowercase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -72,6 +80,9 @@ pub enum DownloadStatus {
/// Transient state: a connection-aware retry is in progress with
/// exponential backoff. The download slot/permit is still held.
Retrying,
/// Aria2 is verifying already-present Torrent data before transfer or
/// after an explicit integrity check.
Verifying,
}
impl DownloadStatus {
@@ -88,6 +99,7 @@ impl DownloadStatus {
Self::Failed => "failed",
Self::Queued => "queued",
Self::Retrying => "retrying",
Self::Verifying => "verifying",
}
}
}
@@ -248,6 +260,15 @@ pub struct DownloadItem {
#[serde(default)]
#[ts(optional)]
pub torrent_encryption_policy: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_file_allocation: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_verify_only: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_verify_restore_status: Option<String>,
}
#[derive(Clone, Debug, Serialize, TS)]
@@ -308,6 +329,49 @@ pub struct TorrentPieceProgressSnapshot {
pub buckets: Vec<u8>,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentFileSelectionEntry {
pub index: u32,
pub relative_path: String,
#[ts(type = "number")]
pub length: u64,
pub selected: bool,
#[ts(type = "number")]
#[ts(optional)]
pub completed_length: Option<u64>,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentFileSelectionSnapshot {
pub files: Vec<TorrentFileSelectionEntry>,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentDetails {
pub info_hash: String,
pub display_name: String,
#[ts(type = "number")]
pub total_bytes: u64,
#[ts(type = "number")]
pub file_count: u32,
#[ts(type = "number")]
pub piece_length: u64,
#[ts(type = "number")]
pub piece_count: u64,
pub private: bool,
pub creation_date: Option<String>,
pub creator: Option<String>,
pub comment: Option<String>,
pub trackers: Vec<String>,
pub web_seeds: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -572,6 +636,8 @@ pub struct PersistedSettings {
pub torrent_separate_seed_slots: bool,
#[serde(default = "default_torrent_max_concurrent_seeds")]
pub torrent_max_concurrent_seeds: u32,
#[serde(default = "default_torrent_ipv6_enabled")]
pub torrent_ipv6_enabled: bool,
#[serde(default)]
pub torrent_listen_port: String,
#[serde(default)]
@@ -590,6 +656,10 @@ pub struct PersistedSettings {
pub torrent_peer_id_prefix: String,
#[serde(default)]
pub torrent_peer_agent: String,
#[serde(default)]
pub torrent_bind_address: String,
#[serde(default = "default_aria2_disk_cache")]
pub aria2_disk_cache: String,
pub custom_user_agent: String,
pub ask_where_to_save_each_file: bool,
pub remember_last_used_download_directory: bool,
+953 -84
View File
File diff suppressed because it is too large Load Diff
+384 -5
View File
@@ -41,6 +41,59 @@ pub const MAX_TORRENT_WEB_SEEDS: usize = 256;
pub const MAX_TORRENT_WEB_SEED_URI_LENGTH: usize = 2_048;
pub const MIN_TORRENT_LISTEN_PORT: u16 = 1024;
pub const DEFAULT_TORRENT_LISTEN_PORT_SPEC: &str = "6881-6999";
pub const DEFAULT_ARIA2_DISK_CACHE: &str = "16M";
pub const MAX_ARIA2_DISK_CACHE_MIB: u64 = 1024;
pub fn normalize_torrent_bind_address(value: Option<&str>) -> Result<Option<String>, String> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if value.len() > MAX_TORRENT_NETWORK_VALUE_LENGTH
|| value.chars().any(char::is_control)
{
return Err("Torrent bind address is too long or contains control characters".to_string());
}
let address = value
.parse::<std::net::IpAddr>()
.map_err(|_| "Torrent bind address must be a valid IPv4 or IPv6 address".to_string())?;
Ok(Some(address.to_string()))
}
pub fn normalize_aria2_disk_cache(value: Option<&str>) -> Result<String, String> {
let value = value.map(str::trim).filter(|value| !value.is_empty()).unwrap_or(DEFAULT_ARIA2_DISK_CACHE);
if value == "0" {
return Ok("0".to_string());
}
let (digits, multiplier, suffix) = match value.as_bytes().last().copied() {
Some(b'k' | b'K') => (&value[..value.len() - 1], 1_u64, "K"),
Some(b'm' | b'M') => (&value[..value.len() - 1], 1024_u64, "M"),
_ => return Err("Aria2 disk cache must be 0 or a positive value ending in K or M".to_string()),
};
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err("Aria2 disk cache must be 0 or a positive value ending in K or M".to_string());
}
let amount = digits
.parse::<u64>()
.map_err(|_| "Aria2 disk cache is too large".to_string())?;
let kib = amount
.checked_mul(multiplier)
.ok_or_else(|| "Aria2 disk cache is too large".to_string())?;
if kib == 0 || kib > MAX_ARIA2_DISK_CACHE_MIB * 1024 {
return Err(format!(
"Aria2 disk cache must be between 1K and {MAX_ARIA2_DISK_CACHE_MIB}M"
));
}
Ok(format!("{amount}{suffix}"))
}
pub fn normalize_torrent_file_allocation(value: Option<&str>) -> Result<String, String> {
match value.map(str::trim).filter(|value| !value.is_empty()) {
None => Ok("prealloc".to_string()),
Some("prealloc") => Ok("prealloc".to_string()),
Some("none") => Ok("none".to_string()),
Some(_) => Err("Torrent file allocation must be prealloc or none".to_string()),
}
}
pub fn clamp_download_connections(connections: i32) -> i32 {
connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX)
@@ -596,6 +649,10 @@ pub struct SpawnPayload {
pub torrent_prioritize_piece: Option<String>,
pub torrent_remove_unselected_file: bool,
pub torrent_encryption_policy: Option<String>,
pub torrent_file_allocation: Option<String>,
pub torrent_verify_only: bool,
pub torrent_verify_restore_status: Option<String>,
pub torrent_verified_length: Option<u64>,
}
/// A sidecar spawner. In production this calls the real aria2/yt-dlp
@@ -1251,6 +1308,27 @@ impl<R: tauri::Runtime> QueueManager<R> {
.collect()
}
/// Temporarily remove one not-yet-admitted task while a caller performs a
/// lifecycle-safe reconfiguration. The admission gate prevents the
/// dispatcher from popping the same task between the caller's validation
/// and mutation of its payload.
pub async fn take_pending_task(&self, id: &str) -> Option<(usize, QueuedTask)> {
let _admission_gate = self.admission_gate.lock().await;
let mut pending = self.pending.lock().await;
let index = pending.iter().position(|task| task.id == id)?;
pending.remove(index).map(|task| (index, task))
}
/// Restore a task removed by `take_pending_task`, preserving its queue
/// position even when another queue's work was admitted meanwhile.
pub async fn restore_pending_task(&self, index: usize, task: QueuedTask) {
let _admission_gate = self.admission_gate.lock().await;
let mut pending = self.pending.lock().await;
let insert_at = index.min(pending.len());
pending.insert(insert_at, task);
self.notify.notify_one();
}
/// Explicitly release a backend registry id (e.g. on un-resumable false paths, removals, or detach).
pub async fn release_registered_id(&self, id: &str) {
self.registered_ids.lock().await.remove(id);
@@ -1525,6 +1603,104 @@ impl<R: tauri::Runtime> QueueManager<R> {
.is_some_and(torrent_seeding_requested)
}
pub async fn aria2_is_torrent(&self, id: &str) -> bool {
self.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.is_torrent)
}
pub async fn aria2_is_torrent_verification(&self, id: &str) -> bool {
self.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.torrent_verify_only)
}
async fn capture_torrent_verification_evidence(&self, id: &str) {
if !self.aria2_is_torrent_verification(id).await {
return;
}
let Some(gid) = self.aria2_gid_for_download(id) else {
return;
};
let Some(mapping) = self.aria2_gid_mapping(&gid) else {
return;
};
let Some(state) = self.app_handle.try_state::<crate::AppState>() else {
return;
};
let port = state.aria2_port.load(std::sync::atomic::Ordering::Relaxed);
let secret = state.aria2_secret.clone();
drop(state);
let Ok(status) = crate::rpc_call(
port,
&secret,
"aria2.tellStatus",
serde_json::json!([gid, ["status", "totalLength", "verifiedLength", "verifyIntegrityPending"]]),
)
.await else {
return;
};
if !self.is_current_aria2_gid_mapping(&gid, &mapping)
|| !self
.is_aria2_control_epoch_current(id, mapping.epoch)
.await
{
return;
}
let status_name = status.get("status").and_then(|value| value.as_str());
let total = status
.get("totalLength")
.and_then(|value| value.as_str().and_then(|value| value.parse::<u64>().ok()).or_else(|| value.as_u64()));
let verified = status
.get("verifiedLength")
.and_then(|value| value.as_str().and_then(|value| value.parse::<u64>().ok()).or_else(|| value.as_u64()));
let pending = status
.get("verifyIntegrityPending")
.is_some_and(|value| value.as_bool() == Some(true) || value.as_str() == Some("true"));
if let Some(total) = Self::complete_torrent_verification_length(
status_name,
pending,
total,
verified,
) {
self.record_torrent_verified_length(id, mapping.epoch, total)
.await;
}
}
fn complete_torrent_verification_length(
status: Option<&str>,
verify_pending: bool,
total: Option<u64>,
verified: Option<u64>,
) -> Option<u64> {
if matches!(status, Some("complete" | "active" | "waiting"))
&& !verify_pending
{
return verified
.zip(total)
.filter(|(verified, total)| verified >= total)
.map(|(_, total)| total);
}
None
}
pub async fn record_torrent_verified_length(&self, id: &str, epoch: u64, length: u64) {
if !self.is_aria2_control_epoch_current(id, epoch).await {
return;
}
if let Some(payload) = self.aria2_payloads.lock().await.get_mut(id) {
if payload.torrent_verify_only {
let current = payload.torrent_verified_length.unwrap_or(0);
payload.torrent_verified_length = Some(current.max(length));
}
}
}
async fn torrent_files_for_payload(
&self,
id: &str,
@@ -3005,6 +3181,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
let buffered_outcome = self.remember_gid(id.clone(), gid.clone()).await;
let install_web_seeds = buffered_outcome.is_none()
&& task.payload.is_torrent
&& !task.payload.torrent_verify_only
&& task.payload.torrent_web_seeds.is_some();
self.finish_aria2_dispatch(&id, lifecycle_epoch).await;
drop(control_guard);
@@ -3150,6 +3327,14 @@ impl<R: tauri::Runtime> QueueManager<R> {
.emit("download-state", DownloadStateEvent::failed(id, error));
}
fn emit_paused_with_error(&self, id: &str, error: String) {
use tauri::Emitter;
let _ = self.app_handle.emit(
"download-state",
DownloadStateEvent::paused_with_error(id, error),
);
}
/// Store gid -> id and return any buffered terminal event for the caller
/// to reconcile against the correct event path. In particular, buffered
/// errors must still pass through transient retry classification.
@@ -3224,7 +3409,30 @@ 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) {
if matches!(&outcome, PendingOutcome::Complete) {
self.capture_torrent_verification_evidence(id).await;
}
let (verification_restore_status, verification_only, verification_observed) = {
let payloads = self.aria2_payloads.lock().await;
payloads
.get(id)
.filter(|payload| payload.torrent_verify_only)
.map(|payload| {
(
payload.torrent_verify_restore_status.clone(),
true,
payload.torrent_verified_length.is_some(),
)
})
.unwrap_or((None, false, false))
};
let outcome = match outcome {
PendingOutcome::Complete if verification_only && !verification_observed => {
PendingOutcome::Error(
"Torrent integrity verification did not produce a complete hash-check result"
.to_string(),
)
}
PendingOutcome::Seeding if self.aria2_torrent_seeding_requested(id).await => {
if !self.seed_capacity_enabled() {
// Keep a budget record even when the legacy single-pool
@@ -3308,10 +3516,26 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
self.release_registered_id(id).await;
self.release_permit(id).await;
self.emit_state(id, DownloadStatus::Completed);
let restored_status = if verification_only {
if verification_restore_status.as_deref() == Some("completed") {
DownloadStatus::Completed
} else {
DownloadStatus::Paused
}
} else {
match verification_restore_status.as_deref() {
Some("paused") => DownloadStatus::Paused,
Some("failed") => DownloadStatus::Failed,
Some("ready") => DownloadStatus::Ready,
Some("staged") => DownloadStatus::Staged,
Some("completed") => DownloadStatus::Completed,
_ => DownloadStatus::Completed,
}
};
self.emit_state(id, restored_status);
}
PendingOutcome::Error(error) => {
if error.to_ascii_lowercase().contains("checksum") {
if !verification_only && error.to_ascii_lowercase().contains("checksum") {
log::warn!("Checksum error detected for {}, cleaning up assets", id);
if let Ok(paths) =
crate::download_ownership::owned_paths_for_id(&self.app_handle, id)
@@ -3322,7 +3546,13 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
}
log::error!("aria2 download {} failed: {}", id, error);
let error = if verification_only {
format!(
"Torrent integrity verification failed; resume the Torrent to repair it: {error}"
)
} else {
error
};
self.clear_aria2_retry_state(id).await;
self.forget_aria2_gid(id).await;
@@ -3345,7 +3575,12 @@ impl<R: tauri::Runtime> QueueManager<R> {
}
self.release_registered_id(id).await;
self.release_permit(id).await;
self.emit_failed(id, error);
if verification_only {
self.emit_paused_with_error(id, error);
} else {
log::error!("aria2 download {} failed: {}", id, error);
self.emit_failed(id, error);
}
}
PendingOutcome::Seeding => unreachable!("seeding outcomes are normalized before terminal cleanup"),
}
@@ -5143,6 +5378,16 @@ fn apply_aria2_torrent_options(
return Ok(());
}
if payload.torrent_verify_only {
options.insert("check-integrity".to_string(), serde_json::json!("true"));
options.insert("hash-check-only".to_string(), serde_json::json!("true"));
options.insert("seed-time".to_string(), serde_json::json!("0"));
options.insert("seed-ratio".to_string(), serde_json::json!("0"));
options.insert("bt-hash-check-seed".to_string(), serde_json::json!("false"));
options.insert("bt-seed-unverified".to_string(), serde_json::json!("false"));
return Ok(());
}
let encryption_policy =
normalize_torrent_encryption_policy(payload.torrent_encryption_policy.as_deref())?;
let (force_encryption, require_crypto, min_crypto_level) =
@@ -5276,6 +5521,8 @@ fn apply_aria2_torrent_options(
serde_json::json!("true"),
);
}
let allocation = normalize_torrent_file_allocation(payload.torrent_file_allocation.as_deref())?;
options.insert("file-allocation".to_string(), serde_json::json!(allocation));
if payload.torrent_check_integrity {
options.insert(
"check-integrity".to_string(),
@@ -5379,6 +5626,9 @@ impl SidecarSpawner for ProductionSpawner {
if !crate::is_safe_path(&resolved_dest, &self.app_handle) {
return Err("Path traversal blocked".to_string());
}
if payload.is_torrent {
crate::torrent::validate_output_name(&payload.filename)?;
}
let proxy_value = payload
.proxy
.as_deref()
@@ -5461,7 +5711,7 @@ impl SidecarSpawner for ProductionSpawner {
let metadata = crate::torrent::parse_torrent_bytes(&bytes)?;
options.insert(
"index-out".to_string(),
serde_json::json!(crate::torrent::aria2_index_outputs(&metadata)),
serde_json::json!(crate::torrent::aria2_index_outputs(&metadata, &payload.filename)),
);
let selected = crate::torrent::validate_selected_indices(
payload.torrent_file_indices.as_deref(),
@@ -6017,6 +6267,15 @@ pub struct EnqueueItem {
pub torrent_encryption_policy: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_file_allocation: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_verify_only: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_verify_restore_status: Option<String>,
#[serde(default)]
#[ts(optional)]
pub lifecycle_generation: Option<String>,
}
@@ -6078,6 +6337,10 @@ impl EnqueueItem {
.torrent_remove_unselected_file
.unwrap_or(false),
torrent_encryption_policy: self.torrent_encryption_policy,
torrent_file_allocation: self.torrent_file_allocation,
torrent_verify_only: self.torrent_verify_only.unwrap_or(false),
torrent_verify_restore_status: self.torrent_verify_restore_status,
torrent_verified_length: None,
},
}
}
@@ -6160,6 +6423,59 @@ mod tests {
);
}
#[test]
fn torrent_network_and_storage_settings_are_normalized_at_the_boundary() {
assert_eq!(
normalize_torrent_bind_address(Some(" 2001:db8::1 ")).unwrap(),
Some("2001:db8::1".to_string())
);
assert_eq!(normalize_torrent_bind_address(Some(" ")).unwrap(), None);
assert!(normalize_torrent_bind_address(Some("localhost")).is_err());
assert!(normalize_torrent_bind_address(Some("127.0.0.1\n--bad")).is_err());
assert_eq!(normalize_aria2_disk_cache(None).unwrap(), "16M");
assert_eq!(normalize_aria2_disk_cache(Some(" 256m ")).unwrap(), "256M");
assert_eq!(normalize_aria2_disk_cache(Some("1024K")).unwrap(), "1024K");
assert_eq!(normalize_aria2_disk_cache(Some("0")).unwrap(), "0");
assert!(normalize_aria2_disk_cache(Some("1025M")).is_err());
assert!(normalize_aria2_disk_cache(Some("16")).is_err());
assert_eq!(normalize_torrent_file_allocation(None).unwrap(), "prealloc");
assert_eq!(normalize_torrent_file_allocation(Some(" none ")).unwrap(), "none");
assert!(normalize_torrent_file_allocation(Some("truncate")).is_err());
}
#[test]
fn torrent_verification_evidence_requires_matching_lengths_but_accepts_empty_data() {
assert_eq!(
QueueManager::<tauri::Wry>::complete_torrent_verification_length(
Some("complete"),
false,
Some(0),
Some(0),
),
Some(0)
);
assert_eq!(
QueueManager::<tauri::Wry>::complete_torrent_verification_length(
Some("complete"),
false,
Some(100),
Some(99),
),
None
);
assert_eq!(
QueueManager::<tauri::Wry>::complete_torrent_verification_length(
Some("complete"),
true,
Some(100),
Some(100),
),
None
);
}
#[test]
fn torrent_options_disable_seeding_when_no_policy_is_saved() {
let mut options = serde_json::Map::new();
@@ -6354,6 +6670,28 @@ mod tests {
);
}
#[test]
fn torrent_verification_uses_hash_only_options_and_ignores_transfer_policy() {
let mut options = serde_json::Map::new();
let payload = SpawnPayload {
is_torrent: true,
torrent_verify_only: true,
torrent_trackers: Some("https://tracker.example/announce".to_string()),
torrent_seed_ratio: Some(1.0),
torrent_file_allocation: Some("none".to_string()),
..Default::default()
};
apply_aria2_torrent_options(&mut options, &payload).unwrap();
assert_eq!(options.get("check-integrity"), Some(&serde_json::json!("true")));
assert_eq!(options.get("hash-check-only"), Some(&serde_json::json!("true")));
assert_eq!(options.get("seed-time"), Some(&serde_json::json!("0")));
assert_eq!(options.get("seed-ratio"), Some(&serde_json::json!("0")));
assert!(!options.contains_key("bt-tracker"));
assert!(!options.contains_key("file-allocation"));
}
#[test]
fn torrent_integrity_check_preserves_an_explicit_seeding_policy() {
let mut options = serde_json::Map::new();
@@ -7279,6 +7617,47 @@ mod tests {
.is_some());
}
#[tokio::test]
async fn pending_torrent_reconfiguration_restores_position_and_payload() {
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));
for id in ["first", "target", "last"] {
manager
.push(QueuedTask {
id: id.to_string(),
queue_id: "queue".to_string(),
kind: TaskKind::Aria2,
lifecycle_generation: 0,
payload: SpawnPayload {
is_torrent: true,
..Default::default()
},
})
.await
.unwrap();
}
let (index, mut task) = manager
.take_pending_task("target")
.await
.expect("target remains pending");
assert_eq!(manager.pending_order(None).await, ["first", "last"]);
task.payload.torrent_file_indices = Some(vec![2]);
manager.restore_pending_task(index, task).await;
assert_eq!(
manager.pending_order(None).await,
["first", "target", "last"]
);
let (_, restored) = manager
.take_pending_task("target")
.await
.expect("target was restored");
assert_eq!(restored.payload.torrent_file_indices, Some(vec![2]));
}
#[tokio::test]
async fn enabling_separate_seed_capacity_counts_existing_seeders() {
let app = tauri::test::mock_builder()
+95 -1
View File
@@ -19,6 +19,9 @@ pub struct TorrentStartupSettings {
pub peer_id_prefix: String,
pub peer_agent: String,
pub dht_message_timeout: u32,
pub ipv6_enabled: bool,
pub bind_address: String,
pub disk_cache: String,
}
fn normalize_torrent_startup_value(
@@ -40,6 +43,22 @@ pub fn torrent_startup_settings(settings: Option<&PersistedSettings>) -> Torrent
let Some(settings) = settings else {
return TorrentStartupSettings::default();
};
let bind_address = normalize_torrent_startup_value(
"Torrent bind address",
&settings.torrent_bind_address,
crate::queue::normalize_torrent_bind_address,
);
let bind_address = if !settings.torrent_ipv6_enabled
&& bind_address
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_ipv6())
{
log::error!("IPv6 Torrent bind address ignored while IPv6 transport is disabled");
String::new()
} else {
bind_address
};
TorrentStartupSettings {
listen_port: normalize_torrent_startup_value(
"TCP listen ports",
@@ -90,6 +109,13 @@ pub fn torrent_startup_settings(settings: Option<&PersistedSettings>) -> Torrent
settings.torrent_dht_message_timeout,
)
.unwrap_or(crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT),
ipv6_enabled: settings.torrent_ipv6_enabled,
bind_address,
disk_cache: crate::queue::normalize_aria2_disk_cache(Some(&settings.aria2_disk_cache))
.unwrap_or_else(|error| {
log::error!("invalid persisted Aria2 disk cache; using default: {error}");
crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string()
}),
}
}
@@ -150,6 +176,13 @@ pub fn canonicalize_torrent_network_settings(stored: &str) -> Result<String, Str
canonicalize_torrent_network_value(state, "torrentLpdInterface", crate::queue::normalize_torrent_lpd_interface);
canonicalize_torrent_network_value(state, "torrentPeerIdPrefix", crate::queue::normalize_torrent_peer_id_prefix);
canonicalize_torrent_network_value(state, "torrentPeerAgent", crate::queue::normalize_torrent_peer_agent);
canonicalize_torrent_network_value(state, "torrentBindAddress", crate::queue::normalize_torrent_bind_address);
let disk_cache = state
.get("aria2DiskCache")
.and_then(Value::as_str)
.and_then(|value| crate::queue::normalize_aria2_disk_cache(Some(value)).ok())
.unwrap_or_else(|| crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string());
state.insert("aria2DiskCache".to_string(), Value::String(disk_cache));
let dht_message_timeout = state
.get("torrentDhtMessageTimeout")
.and_then(Value::as_u64)
@@ -176,6 +209,26 @@ pub fn canonicalize_torrent_network_settings(stored: &str) -> Result<String, Str
{
state.insert("torrentSeparateSeedSlots".to_string(), Value::Bool(false));
}
if !state
.get("torrentIpv6Enabled")
.is_some_and(Value::is_boolean)
{
state.insert("torrentIpv6Enabled".to_string(), Value::Bool(true));
}
if state
.get("torrentIpv6Enabled")
.and_then(Value::as_bool)
== Some(false)
&& state
.get("torrentBindAddress")
.and_then(Value::as_str)
.and_then(|value| value.parse::<std::net::IpAddr>().ok())
.is_some_and(|address| address.is_ipv6())
{
return Err(
"IPv6 Torrent bind address requires IPv6 transport to remain enabled".to_string(),
);
}
serde_json::to_string(&document)
.map_err(|error| format!("failed to encode canonical settings: {error}"))
}
@@ -365,6 +418,7 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
"torrentEnablePex",
"torrentEnableLpd",
"torrentSeparateSeedSlots",
"torrentIpv6Enabled",
] {
sanitize_boolean_setting(state, key);
}
@@ -395,6 +449,12 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
sanitize_torrent_network_string(state, "torrentPeerAgent", |value| {
crate::queue::normalize_torrent_peer_agent(Some(value)).is_ok()
});
sanitize_torrent_network_string(state, "torrentBindAddress", |value| {
crate::queue::normalize_torrent_bind_address(Some(value)).is_ok()
});
sanitize_torrent_network_string(state, "aria2DiskCache", |value| {
crate::queue::normalize_aria2_disk_cache(Some(value)).is_ok()
});
sanitize_allowed_string(
state,
"theme",
@@ -536,7 +596,24 @@ fn validate_settings(settings: &mut PersistedSettings) {
settings.torrent_max_concurrent_seeds = crate::queue::normalize_torrent_max_concurrent_seeds(
settings.torrent_max_concurrent_seeds,
)
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
.unwrap_or(crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS);
settings.torrent_bind_address = crate::queue::normalize_torrent_bind_address(
Some(&settings.torrent_bind_address),
)
.ok()
.flatten()
.unwrap_or_default();
if !settings.torrent_ipv6_enabled
&& settings
.torrent_bind_address
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_ipv6())
{
log::warn!("clearing IPv6 Torrent bind address while IPv6 transport is disabled");
settings.torrent_bind_address.clear();
}
settings.aria2_disk_cache = crate::queue::normalize_aria2_disk_cache(Some(&settings.aria2_disk_cache))
.unwrap_or_else(|_| crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string());
settings.torrent_listen_port = crate::queue::normalize_torrent_port_spec(
Some(&settings.torrent_listen_port),
"TCP listen ports",
@@ -792,6 +869,7 @@ fn default_settings() -> PersistedSettings {
torrent_dht_message_timeout: crate::queue::DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
torrent_separate_seed_slots: false,
torrent_max_concurrent_seeds: crate::queue::DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
torrent_ipv6_enabled: true,
torrent_listen_port: String::new(),
torrent_dht_listen_port: String::new(),
torrent_external_ip: String::new(),
@@ -801,6 +879,8 @@ fn default_settings() -> PersistedSettings {
torrent_lpd_interface: String::new(),
torrent_peer_id_prefix: String::new(),
torrent_peer_agent: String::new(),
torrent_bind_address: String::new(),
aria2_disk_cache: crate::queue::DEFAULT_ARIA2_DISK_CACHE.to_string(),
custom_user_agent: String::new(),
ask_where_to_save_each_file: false,
remember_last_used_download_directory: false,
@@ -1225,6 +1305,20 @@ mod tests {
assert_eq!(canonical["state"]["torrentSeparateSeedSlots"], false);
}
#[test]
fn rejects_ipv6_bind_address_when_transport_is_disabled() {
let stored = json!({
"state": {
"torrentIpv6Enabled": false,
"torrentBindAddress": "2001:db8::10"
}
});
let error = canonicalize_torrent_network_settings(&stored.to_string())
.expect_err("IPv6 bind must not be accepted with IPv6 transport disabled");
assert!(error.contains("IPv6 Torrent bind address"));
}
#[test]
fn startup_settings_revalidate_values_at_the_aria2_boundary() {
let stored = json!({
+191 -9
View File
@@ -361,6 +361,133 @@ pub fn parse_torrent_bytes(bytes: &[u8]) -> Result<ParsedTorrent, String> {
parse_info(info)
}
fn bounded_optional_text(value: Option<&BencodeValue>, limit: usize) -> Option<String> {
let BencodeValue::Bytes(bytes) = value? else {
return None;
};
if bytes.len() > limit {
return None;
}
String::from_utf8(bytes.clone())
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn bounded_uri(value: &str, schemes: &[&str]) -> Option<String> {
if value.len() > 2_048 || value.chars().any(char::is_control) {
return None;
}
let parsed = url::Url::parse(value).ok()?;
if !schemes.contains(&parsed.scheme())
|| parsed.host_str().is_none_or(str::is_empty)
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.fragment().is_some()
{
return None;
}
Some(parsed.to_string())
}
fn collect_torrent_uris(value: Option<&BencodeValue>, schemes: &[&str]) -> Vec<String> {
let mut values = Vec::new();
let mut append = |value: &BencodeValue| {
if let BencodeValue::Bytes(bytes) = value {
if let Ok(value) = String::from_utf8(bytes.clone()) {
if let Some(uri) = bounded_uri(value.trim(), schemes) {
if !values.contains(&uri) && values.len() < 256 {
values.push(uri);
}
}
}
}
};
match value {
Some(BencodeValue::Bytes(_)) => append(value.unwrap()),
Some(BencodeValue::List(entries)) => {
for entry in entries {
append(entry);
}
}
_ => {}
}
values
}
pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result<crate::ipc::TorrentDetails, String> {
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
return Err(format!(
"torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes"
));
}
let root = match Parser::new(bytes).parse()? {
BencodeValue::Dict(value) => value,
_ => return Err("torrent root is not a dictionary".to_string()),
};
let info = root
.get(b"info".as_slice())
.ok_or_else(|| "torrent metadata is missing info".to_string())?;
let parsed = parse_info(info)?;
let info_dict = match info {
BencodeValue::Dict(value) => value,
_ => return Err("torrent info dictionary is invalid".to_string()),
};
let piece_length = positive_length(info_dict.get(b"piece length".as_slice()), "piece length")?;
let piece_count = match info_dict.get(b"pieces".as_slice()) {
Some(BencodeValue::Bytes(pieces)) if pieces.len() % 20 == 0 => (pieces.len() / 20) as u64,
_ => return Err("torrent pieces field is invalid".to_string()),
};
let creation_date = root
.get(b"creation date".as_slice())
.and_then(|value| match value {
BencodeValue::Integer(value) if *value >= 0 => chrono::DateTime::<chrono::Utc>::from_timestamp(*value, 0)
.map(|date| date.to_rfc3339()),
_ => None,
});
let trackers = collect_torrent_uris(root.get(b"announce".as_slice()), &["http", "https", "udp"])
.into_iter()
.chain(root.get(b"announce-list".as_slice()).into_iter().flat_map(|value| {
let mut trackers = Vec::new();
if let BencodeValue::List(tiers) = value {
for tier in tiers {
if let BencodeValue::List(entries) = tier {
for entry in entries {
trackers.extend(collect_torrent_uris(Some(entry), &["http", "https", "udp"]));
}
}
}
}
trackers
}))
.fold(Vec::new(), |mut result, tracker| {
if !result.contains(&tracker) && result.len() < 256 {
result.push(tracker);
}
result
});
Ok(crate::ipc::TorrentDetails {
info_hash: parsed.info_hash,
display_name: parsed.name,
total_bytes: parsed.total_bytes,
file_count: parsed.files.len() as u32,
piece_length,
piece_count,
private: matches!(info_dict.get(b"private".as_slice()), Some(BencodeValue::Integer(1))),
creation_date,
creator: bounded_optional_text(
root.get(b"created by.utf-8".as_slice()).or_else(|| root.get(b"created by".as_slice())),
256,
),
comment: bounded_optional_text(
root.get(b"comment.utf-8".as_slice()).or_else(|| root.get(b"comment".as_slice())),
4_096,
),
trackers,
web_seeds: collect_torrent_uris(root.get(b"url-list".as_slice()), &["http", "https"]),
})
}
pub fn torrent_metadata_is_safe_for_plain_magnet_reuse(bytes: &[u8]) -> Result<bool, String> {
if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES {
return Err(format!(
@@ -499,31 +626,53 @@ pub fn to_metadata(parsed: ParsedTorrent, torrent_path: Option<String>) -> Torre
/// Aria2's BitTorrent output is controlled by `index-out`, not `out`. Keep
/// these values derived from the validated, canonical paths so the daemon's
/// actual files stay aligned with Firelink's ownership registry.
pub fn aria2_index_outputs(parsed: &ParsedTorrent) -> Vec<String> {
pub fn validate_output_name(name: &str) -> Result<(), String> {
if name.is_empty()
|| name != name.trim()
|| name == "."
|| name == ".."
|| name.ends_with(['.', ' '])
|| name.chars().any(|character| {
character.is_control()
|| matches!(character, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*')
})
|| crate::platform::is_windows_reserved_filename(name)
|| crate::download_ownership::canonical_download_filename(name) != name
{
return Err("Torrent output name is not a safe single path component".to_string());
}
Ok(())
}
pub fn aria2_index_outputs(parsed: &ParsedTorrent, output_name: &str) -> Vec<String> {
parsed
.files
.iter()
.map(|file| {
let output = if parsed.files.len() == 1 {
file.path.clone()
output_name.to_string()
} else {
format!("{}/{}", parsed.name, file.path)
format!("{output_name}/{}", file.path)
};
format!("{}={output}", file.index)
})
.collect()
}
pub fn aria2_output_paths(parsed: &ParsedTorrent, selected: Option<&[u32]>) -> Vec<String> {
pub fn aria2_output_paths(
parsed: &ParsedTorrent,
selected: Option<&[u32]>,
output_name: &str,
) -> Vec<String> {
parsed
.files
.iter()
.filter(|file| selected.is_none_or(|indices| indices.contains(&file.index)))
.map(|file| {
if parsed.files.len() == 1 {
file.path.clone()
output_name.to_string()
} else {
format!("{}/{}", parsed.name, file.path)
format!("{output_name}/{}", file.path)
}
})
.collect()
@@ -699,7 +848,11 @@ pub fn validate_selected_indices(
let mut normalized = selected.to_vec();
normalized.sort_unstable();
normalized.dedup();
Ok(Some(normalized))
if normalized.len() == file_count {
Ok(None)
} else {
Ok(Some(normalized))
}
}
pub async fn prepare_local_torrent<R: tauri::Runtime>(
@@ -954,6 +1107,32 @@ mod tests {
assert_eq!(parsed.info_hash.len(), 40);
}
#[test]
fn validates_torrent_output_names_as_single_safe_components() {
for name in ["test", "My Torrent (1)", "archive.tar"] {
validate_output_name(name).expect("ordinary output names should be accepted");
}
for name in ["", " test", "test ", ".", "..", "a/b", "a\\b", "CON", "a?.bin"] {
assert!(validate_output_name(name).is_err(), "{name:?}");
}
}
#[test]
fn exposes_bounded_torrent_details_from_metadata() {
let mut bytes = b"d4:infod6:lengthi5e4:name4:test12:piece lengthi2e6:pieces20:".to_vec();
bytes.extend([0_u8; 20]);
bytes.extend_from_slice(b"ee");
let details = torrent_details_from_bytes(&bytes).expect("details should parse");
assert_eq!(details.display_name, "test");
assert_eq!(details.total_bytes, 5);
assert_eq!(details.file_count, 1);
assert_eq!(details.piece_length, 2);
assert_eq!(details.piece_count, 1);
assert!(!details.private);
}
#[test]
fn parses_multi_file_torrent_and_rejects_traversal() {
let parsed = parse_torrent_bytes(
@@ -1142,8 +1321,11 @@ mod tests {
)
.expect("multi-file torrent should parse");
assert_eq!(
aria2_index_outputs(&parsed),
vec!["1=root/root/a.txt".to_string(), "2=root/root/b.bin".to_string()]
aria2_index_outputs(&parsed, "custom-name"),
vec![
"1=custom-name/root/a.txt".to_string(),
"2=custom-name/root/b.bin".to_string(),
]
);
}
+1 -1
View File
@@ -3,4 +3,4 @@ import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus";
import type { TorrentWebSeed } from "./TorrentWebSeed";
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, torrentSeedRemaining?: number, torrentWebSeeds?: Array<TorrentWebSeed>, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: 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, torrentSeedRemaining?: number, torrentWebSeeds?: Array<TorrentWebSeed>, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: 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 DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying";
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying" | "verifying";
+1 -1
View File
@@ -1,4 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TorrentWebSeed } from "./TorrentWebSeed";
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_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: 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_seed_remaining?: number, torrent_web_seeds?: Array<TorrentWebSeed>, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, torrent_file_allocation?: string, torrent_verify_only?: boolean, torrent_verify_restore_status?: string, lifecycle_generation?: string, };
+1 -1
View File
@@ -11,4 +11,4 @@ import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
import type { WindowControlStyle } from "./WindowControlStyle";
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TorrentDetails = { infoHash: string, displayName: string, totalBytes: number, fileCount: number, pieceLength: number, pieceCount: number, private: boolean, creationDate: string | null, creator: string | null, comment: string | null, trackers: Array<string>, webSeeds: Array<string>, };
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TorrentFileSelectionEntry = { index: number, relativePath: string, length: number, selected: boolean, completedLength?: number, };
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { TorrentFileSelectionEntry } from "./TorrentFileSelectionEntry";
export type TorrentFileSelectionSnapshot = { files: Array<TorrentFileSelectionEntry>, };
+7 -5
View File
@@ -178,20 +178,20 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
};
}, [isActionVisible, updateActionPosition]);
const displayFraction = download.status === 'downloading' || download.status === 'seeding'
const displayFraction = download.status === 'downloading' || download.status === 'verifying' || download.status === 'seeding'
? liveProgress?.fraction ?? download.fraction ?? 0
: download.fraction ?? 0;
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
const displaySpeed = download.status === 'seeding'
? liveProgress?.upload_speed ?? '-'
: download.status === 'downloading'
: download.status === 'downloading' || download.status === 'verifying'
? liveProgress?.speed ?? download.speed
: download.status === 'processing'
? t($ => $.downloads.values.processing)
: '-';
const displayEta = download.status === 'seeding'
? '-'
: download.status === 'downloading'
: download.status === 'downloading' || download.status === 'verifying'
? liveProgress?.eta ?? download.eta
: download.status === 'processing'
? t($ => $.downloads.values.muxing)
@@ -297,6 +297,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.status === 'paused' ? 'paused' :
download.status === 'seeding' ? 'seeding' :
download.status === 'processing' ? 'processing' :
download.status === 'verifying' ? 'processing' :
download.status === 'queued' || download.status === 'staged' ? 'queued' :
download.status === 'retrying' ? 'retrying' : ''
}`}
@@ -319,7 +320,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
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 === 'processing' ? 'download-status-processing' :
download.status === 'verifying' ? 'download-status-processing' :
download.status === 'downloading' ? 'download-status-downloading' :
download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' :
download.status === 'retrying' ? 'download-status-retrying' : ''
@@ -332,7 +334,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
{downloadStatusLabel} #{queueIndex + 1}
</span>
</>
) : download.status === 'downloading' ? (
) : download.status === 'downloading' || download.status === 'verifying' ? (
displayPercent
) : download.status === 'seeding' ? (
displayPercent
+291 -7
View File
@@ -6,6 +6,8 @@ import { useSettingsStore } from '../store/useSettingsStore';
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot';
import type { TorrentPieceProgressSnapshot } from '../bindings/TorrentPieceProgressSnapshot';
import type { TorrentFileSelectionSnapshot } from '../bindings/TorrentFileSelectionSnapshot';
import type { TorrentDetails } from '../bindings/TorrentDetails';
import type { TorrentWebSeed } from '../bindings/TorrentWebSeed';
import { invokeCommand as invoke } from '../ipc';
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
@@ -22,7 +24,7 @@ import {
formatDownloadTotal,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
import { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, resolveDownloadConnections, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy } from '../utils/downloads';
import { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, resolveDownloadConnections, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
import { useTranslation } from 'react-i18next';
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
@@ -43,10 +45,10 @@ const formatLastTry = (
};
const isPeerDiagnosticsStatus = (status: string): boolean =>
['downloading', 'seeding', 'retrying'].includes(status);
['downloading', 'verifying', 'seeding', 'retrying'].includes(status);
const isTorrentFileProgressStatus = (status: string): boolean =>
['downloading', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
const formatPeerSpeed = (bytesPerSecond: number): string =>
`${formatDownloadBytes(bytesPerSecond)}/s`;
@@ -95,6 +97,7 @@ export const PropertiesModal = () => {
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
const [torrentRemoveUnselectedFile, setTorrentRemoveUnselectedFile] = useState(false);
const [torrentEncryptionPolicy, setTorrentEncryptionPolicy] = useState<TorrentEncryptionPolicy>(TORRENT_ENCRYPTION_POLICY_DISABLED);
const [torrentFileAllocation, setTorrentFileAllocation] = useState<TorrentFileAllocation>('prealloc');
const [torrentTrackers, setTorrentTrackers] = useState('');
const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState('');
const [torrentTrackerConnectTimeout, setTorrentTrackerConnectTimeout] = useState('');
@@ -106,6 +109,11 @@ export const PropertiesModal = () => {
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
const [torrentFileProgress, setTorrentFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
const [torrentFileSelection, setTorrentFileSelection] = useState<TorrentFileSelectionSnapshot | null>(null);
const [torrentDetails, setTorrentDetails] = useState<TorrentDetails | null>(null);
const [torrentDetailsError, setTorrentDetailsError] = useState(false);
const [isTorrentDetailsPending, setIsTorrentDetailsPending] = useState(false);
const [isTorrentVerifyPending, setIsTorrentVerifyPending] = useState(false);
const [torrentFileProgressError, setTorrentFileProgressError] = useState(false);
const [isTorrentFileProgressPending, setIsTorrentFileProgressPending] = useState(false);
const [torrentPieceProgress, setTorrentPieceProgress] = useState<TorrentPieceProgressSnapshot | null>(null);
@@ -132,9 +140,14 @@ export const PropertiesModal = () => {
const [errorMessage, setErrorMessage] = useState('');
const [isPauseResumePending, setIsPauseResumePending] = useState(false);
const torrentFileProgressByIndex = new Map(
(torrentFileProgress?.files ?? []).map(file => [file.index, file])
);
const actionRequestRef = useRef(0);
const peerDiagnosticsRequestRef = useRef(0);
const torrentFileProgressRequestRef = useRef(0);
const torrentFileSelectionRequestRef = useRef(0);
const torrentDetailsRequestRef = useRef(0);
const torrentPieceProgressRequestRef = useRef(0);
const torrentWebSeedsRequestRef = useRef(0);
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
@@ -154,6 +167,10 @@ export const PropertiesModal = () => {
setTorrentFileProgress(null);
setTorrentFileProgressError(false);
setIsTorrentFileProgressPending(false);
torrentDetailsRequestRef.current += 1;
setTorrentDetails(null);
setTorrentDetailsError(false);
setIsTorrentDetailsPending(false);
torrentPieceProgressRequestRef.current += 1;
setTorrentPieceProgress(null);
setTorrentPieceProgressError(false);
@@ -227,6 +244,7 @@ export const PropertiesModal = () => {
setTorrentCheckIntegrity(activeItem.torrentCheckIntegrity === true);
setTorrentRemoveUnselectedFile(activeItem.torrentRemoveUnselectedFile === true);
setTorrentEncryptionPolicy(normalizeTorrentEncryptionPolicy(activeItem.torrentEncryptionPolicy) || TORRENT_ENCRYPTION_POLICY_DISABLED);
setTorrentFileAllocation(normalizeTorrentFileAllocation(activeItem.torrentFileAllocation) || 'prealloc');
setTorrentTrackers(activeItem.torrentTrackers || '');
setTorrentExcludeTrackers(activeItem.torrentExcludeTrackers || '');
setTorrentTrackerConnectTimeout(activeItem.torrentTrackerConnectTimeout === undefined ? '' : String(activeItem.torrentTrackerConnectTimeout));
@@ -244,6 +262,58 @@ export const PropertiesModal = () => {
}
}, [selectedPropertiesDownloadId, setSelectedPropertiesDownloadId]);
useEffect(() => {
torrentFileSelectionRequestRef.current += 1;
setTorrentFileSelection(null);
if (!selectedPropertiesDownloadId || !item?.isTorrent) return;
const requestId = torrentFileSelectionRequestRef.current;
const propertiesDownloadId = item.id;
void invoke('get_torrent_file_selection', { id: propertiesDownloadId })
.then(snapshot => {
if (
requestId === torrentFileSelectionRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentFileSelection(snapshot);
}
})
.catch(() => {
if (requestId === torrentFileSelectionRequestRef.current) setTorrentFileSelection(null);
});
}, [item?.id, item?.isTorrent, item?.torrentPath, selectedPropertiesDownloadId]);
useEffect(() => {
torrentDetailsRequestRef.current += 1;
setTorrentDetails(null);
setTorrentDetailsError(false);
setIsTorrentDetailsPending(false);
if (!selectedPropertiesDownloadId || !item?.isTorrent || !item.torrentPath) return;
const requestId = torrentDetailsRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentDetailsPending(true);
void invoke('get_torrent_details', { id: propertiesDownloadId })
.then(details => {
if (
requestId === torrentDetailsRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentDetails(details);
}
})
.catch(() => {
if (
requestId === torrentDetailsRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentDetailsError(true);
}
})
.finally(() => {
if (requestId === torrentDetailsRequestRef.current) setIsTorrentDetailsPending(false);
});
}, [item?.id, item?.isTorrent, item?.torrentPath, selectedPropertiesDownloadId]);
useEffect(() => {
const activeLimit = item?.speedLimit?.trim();
setLiveSpeedLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
@@ -569,6 +639,32 @@ export const PropertiesModal = () => {
}
};
const handleVerifyTorrentData = async () => {
if (!item?.isTorrent || isTorrentVerifyPending) return;
const restoreStatus = item.status;
const previousVerifyOnly = item.torrentVerifyOnly;
const previousRestoreStatus = item.torrentVerifyRestoreStatus;
// Mark the maintenance lifecycle before invoking the command so a very
// fast queued/completed event cannot be mistaken for the normal download
// lifecycle. The backend persists the same markers before dispatching.
useDownloadStore.getState().updateDownload(item.id, {
torrentVerifyOnly: true,
torrentVerifyRestoreStatus: restoreStatus
});
setIsTorrentVerifyPending(true);
try {
await invoke('verify_torrent_data', { id: item.id });
} catch (error) {
useDownloadStore.getState().updateDownload(item.id, {
torrentVerifyOnly: previousVerifyOnly,
torrentVerifyRestoreStatus: previousRestoreStatus
});
setErrorMessage(error instanceof Error ? error.message : String(error));
} finally {
setIsTorrentVerifyPending(false);
}
};
const handleSave = async () => {
if (!url.trim()) {
setErrorMessage(t($ => $.properties.enterValidUrl));
@@ -638,6 +734,21 @@ export const PropertiesModal = () => {
setErrorMessage(t($ => $.properties.torrentEncryptionPolicyInvalid));
return;
}
const selectedTorrentIndices = torrentFileSelection
? torrentFileSelection.files.filter(file => file.selected).map(file => file.index)
: [];
const allTorrentFilesSelected = Boolean(
torrentFileSelection
&& selectedTorrentIndices?.length === torrentFileSelection.files.length
);
if (torrentFileSelection && selectedTorrentIndices?.length === 0) {
setErrorMessage(t($ => $.properties.torrentFileSelectionRequired));
return;
}
if (item.isTorrent && torrentRemoveUnselectedFile && torrentFileSelection && allTorrentFilesSelected) {
setErrorMessage(t($ => $.properties.torrentRemoveUnselectedFileSelectionRequired));
return;
}
if (
item.isTorrent
&& torrentRemoveUnselectedFile
@@ -676,12 +787,18 @@ export const PropertiesModal = () => {
: undefined,
torrentStopTimeout: normalizedStopTimeout,
torrentPrioritizePiece: normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined,
torrentRemoveUnselectedFile: item.torrentFileIndices !== undefined
torrentFileIndices: torrentFileSelection
? (allTorrentFilesSelected ? undefined : selectedTorrentIndices)
: item.torrentFileIndices,
torrentRemoveUnselectedFile: (torrentFileSelection
? !allTorrentFilesSelected
: item.torrentFileIndices !== undefined)
? torrentRemoveUnselectedFile
: undefined,
torrentEncryptionPolicy: torrentEncryptionPolicy !== TORRENT_ENCRYPTION_POLICY_DISABLED
? torrentEncryptionPolicy
: undefined,
torrentFileAllocation,
}
: {}),
...(connectionsDirty
@@ -839,6 +956,9 @@ export const PropertiesModal = () => {
const liveTorrentUploadLimitAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status);
const liveTorrentPeerOptionsAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status);
const torrentPeerDiagnosticsAvailable = item.isTorrent && isPeerDiagnosticsStatus(item.status);
const torrentFileSelectionIsEmpty = item.isTorrent
&& torrentFileSelection !== null
&& !torrentFileSelection.files.some(file => file.selected);
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
const observedConnectionTotal = Math.max(
1,
@@ -919,7 +1039,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 === 'seeding' || item.status === 'retrying') { statusColor = 'text-blue-500'; StatusIcon = Play; }
else if (item.status === 'downloading' || item.status === 'verifying' || 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; }
@@ -1105,6 +1225,72 @@ export const PropertiesModal = () => {
<div className="col-start-2 text-[11px] text-text-muted">
{t($ => $.properties.torrentPeerOptionsSavedHint)}
</div>
{torrentFileSelection && (
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
{t($ => $.properties.torrentFileSelection)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="app-button px-2.5 text-[11px] disabled:opacity-50"
disabled={transferLocked || torrentFileSelection.files.length === 0}
onClick={() => setTorrentFileSelection(current => current
? { ...current, files: current.files.map(file => ({ ...file, selected: true })) }
: current)}
>
{t($ => $.properties.torrentFileSelectionAll)}
</button>
<button
type="button"
className="app-button px-2.5 text-[11px] disabled:opacity-50"
disabled={transferLocked || torrentFileSelection.files.length === 0}
onClick={() => setTorrentFileSelection(current => current
? { ...current, files: current.files.map(file => ({ ...file, selected: false })) }
: current)}
>
{t($ => $.properties.torrentFileSelectionClear)}
</button>
</div>
</div>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentFileSelectionHint)}
</p>
<div className="max-h-48 overflow-auto rounded border border-border-modal/60">
{torrentFileSelection.files.map(file => (
<label key={file.index} className="flex items-center gap-2 border-b border-border-modal/40 px-2 py-1.5 text-[11px] last:border-b-0">
<input
type="checkbox"
checked={file.selected}
disabled={transferLocked}
onChange={event => setTorrentFileSelection(current => current
? {
...current,
files: current.files.map(candidate => candidate.index === file.index
? { ...candidate, selected: event.currentTarget.checked }
: candidate)
}
: current)}
className="accent-accent disabled:opacity-50"
/>
<span className="font-mono text-text-muted">{file.index}</span>
<span className="min-w-0 flex-1 truncate" dir="auto" title={file.relativePath}>{file.relativePath}</span>
<span className="text-end text-text-muted whitespace-nowrap">
{(() => {
const progress = torrentFileProgressByIndex.get(file.index);
const completedLength = progress?.completedLength ?? file.completedLength ?? 0;
const percentage = file.length === 0
? 100
: Math.round((completedLength / file.length) * 100);
return `${formatDownloadBytes(completedLength)} / ${formatDownloadBytes(file.length)} (${percentage}%)`;
})()}
</span>
</label>
))}
</div>
</div>
)}
<div className="col-start-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-semibold text-text-primary">
@@ -1442,6 +1628,25 @@ export const PropertiesModal = () => {
{t($ => $.properties.torrentPrioritizePieceHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-file-allocation-properties">
{t($ => $.properties.torrentFileAllocation)}
</label>
<div>
<select
id="torrent-file-allocation-properties"
value={torrentFileAllocation}
onChange={event => setTorrentFileAllocation(event.currentTarget.value as TorrentFileAllocation)}
disabled={transferLocked}
aria-describedby="torrent-file-allocation-properties-hint"
className="app-control max-w-56 px-2.5 py-1.5 text-xs disabled:opacity-50"
>
<option value="prealloc">{t($ => $.properties.torrentFileAllocationPrealloc)}</option>
<option value="none">{t($ => $.properties.torrentFileAllocationNone)}</option>
</select>
<p id="torrent-file-allocation-properties-hint" className="mt-1 text-[11px] text-text-muted">
{t($ => $.properties.torrentFileAllocationHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-encryption-policy-properties">
{t($ => $.properties.torrentEncryptionPolicy)}
</label>
@@ -1485,6 +1690,18 @@ export const PropertiesModal = () => {
{t($ => $.properties.torrentVerifyIntegrityHint)}
</span>
</label>
<div className="col-start-2">
<button
type="button"
onClick={() => void handleVerifyTorrentData()}
disabled={isTorrentVerifyPending || getTransferLocked(item.status) || !['completed', 'paused', 'failed'].includes(item.status)}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentVerifyPending
? t($ => $.properties.torrentVerifyNowLoading)
: t($ => $.properties.torrentVerifyNow)}
</button>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-remove-unselected-file">
{t($ => $.properties.torrentRemoveUnselectedFile)}
</label>
@@ -1682,6 +1899,73 @@ export const PropertiesModal = () => {
</div>
</section>
{item.isTorrent && (
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
{t($ => $.properties.torrentDetails)}
</h3>
{isTorrentDetailsPending && (
<p className="text-xs text-text-muted">{t($ => $.properties.torrentDetailsLoading)}</p>
)}
{torrentDetailsError && (
<p className="text-xs text-red-400">{t($ => $.properties.torrentDetailsUnavailable)}</p>
)}
{torrentDetails && (
<div className="grid grid-cols-[100px_1fr] gap-x-4 gap-y-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs">
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsDisplayName)}</span>
<span className="text-text-primary break-words" dir="auto">{torrentDetails.displayName}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsInfoHash)}</span>
<span className="font-mono text-text-primary break-all">{torrentDetails.infoHash}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsSize)}</span>
<span className="text-text-primary">{formatDownloadBytes(torrentDetails.totalBytes)}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsFiles)}</span>
<span className="text-text-primary">{torrentDetails.fileCount}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsPieces)}</span>
<span className="text-text-primary">{torrentDetails.pieceCount} × {formatDownloadBytes(torrentDetails.pieceLength)}</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsPrivate)}</span>
<span className="text-text-primary">{torrentDetails.private
? t($ => $.properties.torrentDetailsPrivateYes)
: t($ => $.properties.torrentDetailsPrivateNo)}</span>
{torrentDetails.creationDate && (
<>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsCreated)}</span>
<span className="text-text-primary">{formatDateTime(torrentDetails.creationDate, {
locale: i18n.language,
calendar: calendarPreference,
options: { dateStyle: 'medium', timeStyle: 'short' }
})}</span>
</>
)}
{torrentDetails.creator && (
<>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsCreator)}</span>
<span className="text-text-primary break-words" dir="auto">{torrentDetails.creator}</span>
</>
)}
{torrentDetails.comment && (
<>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsComment)}</span>
<span className="whitespace-pre-wrap break-words text-text-primary" dir="auto">{torrentDetails.comment}</span>
</>
)}
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsTrackers)}</span>
<span className="text-text-primary break-words" dir="auto">
{torrentDetails.trackers.length > 0 ? torrentDetails.trackers.join(', ') : '—'}
</span>
<span className="text-text-muted text-right">{t($ => $.properties.torrentDetailsWebSeeds)}</span>
<span className="text-text-primary break-words" dir="auto">
{torrentDetails.webSeeds.length > 0 ? torrentDetails.webSeeds.join(', ') : '—'}
</span>
{torrentDetails.private && (
<p className="col-span-2 text-[11px] text-text-muted">
{t($ => $.properties.torrentDetailsPrivateHint)}
</p>
)}
</div>
)}
</section>
)}
{item.isTorrent && (
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
@@ -1794,8 +2078,8 @@ export const PropertiesModal = () => {
<button
type="button"
onClick={handleSave}
disabled={transferLocked}
className={`app-button app-button-primary px-4 text-xs ${transferLocked ? 'opacity-50 cursor-not-allowed' : ''}`}
disabled={transferLocked || torrentFileSelectionIsEmpty}
className={`app-button app-button-primary px-4 text-xs ${transferLocked || torrentFileSelectionIsEmpty ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<CheckCircle size={14} />
{t($ => $.properties.save)}
+41
View File
@@ -1280,6 +1280,19 @@ runEngineChecks(false);
type="checkbox"
checked={settings.torrentEnableDht6}
onChange={(event) => settings.setTorrentEnableDht6(event.target.checked)}
disabled={!settings.torrentIpv6Enabled}
className="mac-switch disabled:opacity-50"
/>
</label>
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentIpv6Enabled)}</span>
<small>{t($ => $.settings.network.torrentIpv6EnabledDescription)}</small>
</div>
<input
type="checkbox"
checked={settings.torrentIpv6Enabled}
onChange={(event) => settings.setTorrentIpv6Enabled(event.target.checked)}
className="mac-switch"
/>
</label>
@@ -1328,6 +1341,20 @@ runEngineChecks(false);
aria-label={t($ => $.settings.network.torrentListenPort)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentBindAddress)}</span>
<small>{t($ => $.settings.network.torrentBindAddressDescription)}</small>
</div>
<input
type="text"
value={settings.torrentBindAddress}
onChange={(event) => settings.setTorrentBindAddress(event.target.value)}
placeholder="192.0.2.10 or 2001:db8::10"
className="app-control settings-network-input"
aria-label={t($ => $.settings.network.torrentBindAddress)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentDhtListenPort)}</span>
@@ -1519,6 +1546,20 @@ runEngineChecks(false);
aria-label={t($ => $.settings.network.torrentMaxOpenFiles)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.aria2DiskCache)}</span>
<small>{t($ => $.settings.network.aria2DiskCacheDescription)}</small>
</div>
<input
type="text"
value={settings.aria2DiskCache}
onChange={(event) => settings.setAria2DiskCache(event.target.value)}
placeholder="16M"
className="app-control settings-network-input text-center"
aria-label={t($ => $.settings.network.aria2DiskCache)}
/>
</div>
<div className="mac-settings-row settings-network-row">
<div className="settings-row-label">
<span>{t($ => $.settings.network.torrentOverallUploadLimit)}</span>
+35
View File
@@ -89,6 +89,7 @@ const common = {
queued: 'Queued',
downloading: 'Downloading',
processing: 'Processing',
verifying: 'Verifying',
seeding: 'Seeding',
waitingToSeed: 'Waiting to seed',
paused: 'Paused',
@@ -251,6 +252,8 @@ const common = {
torrentTrackerIntervalInvalid: 'Tracker interval must be a whole number from 0 to 604800 seconds',
torrentVerifyIntegrity: 'Verify Torrent integrity',
torrentVerifyIntegrityHint: 'Applied when this Torrent starts or retries. It may recheck pieces and download damaged data; active transfers cannot change it.',
torrentVerifyNow: 'Verify now',
torrentVerifyNowLoading: 'Verifying…',
torrentMaxPeers: 'Maximum Torrent peers',
torrentPeerSpeedLimit: 'Peer speed threshold',
torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000',
@@ -262,6 +265,11 @@ const common = {
torrentPeerDiagnosticsFailed: 'Could not read Torrent peer diagnostics.',
torrentPeerDiagnosticsHint: 'Speeds and connection flags only are shown; peer IPs, ports, IDs, and bitfields are not retained.',
torrentFileProgress: 'Torrent file progress',
torrentFileSelection: 'Torrent file selection',
torrentFileSelectionHint: 'Choose which files to download. Selecting every file removes the filter; at least one file must remain selected.',
torrentFileSelectionRequired: 'Select at least one Torrent file.',
torrentFileSelectionAll: 'Select all',
torrentFileSelectionClear: 'Clear',
torrentFileProgressRefresh: 'Refresh',
torrentFileProgressLoading: 'Loading file progress…',
torrentFileProgressUnavailable: 'File progress is available while this Torrent is active or paused.',
@@ -300,6 +308,27 @@ const common = {
torrentPrioritizePieceHint: 'Optional Aria2 preview policy: head, tail, or both; each may use a size such as 1M. Changes apply when the Torrent starts or retries.',
torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M',
torrentEncryptionPolicy: 'Torrent encryption policy',
torrentFileAllocation: 'Torrent file allocation',
torrentFileAllocationPrealloc: 'Preallocate files',
torrentFileAllocationNone: 'Allocate as needed',
torrentFileAllocationHint: 'Preallocation reserves the selected files before transfer. Allocation as needed avoids that upfront disk reservation.',
torrentDetails: 'Torrent details',
torrentDetailsLoading: 'Loading Torrent details…',
torrentDetailsUnavailable: 'Torrent details are not available.',
torrentDetailsDisplayName: 'Display name',
torrentDetailsInfoHash: 'Info hash',
torrentDetailsSize: 'Total size',
torrentDetailsFiles: 'Files',
torrentDetailsPieces: 'Pieces',
torrentDetailsPrivate: 'Private',
torrentDetailsPrivateYes: 'Yes',
torrentDetailsPrivateNo: 'No',
torrentDetailsCreated: 'Created',
torrentDetailsCreator: 'Creator',
torrentDetailsComment: 'Comment',
torrentDetailsTrackers: 'Trackers',
torrentDetailsWebSeeds: 'Embedded web seeds',
torrentDetailsPrivateHint: 'This private Torrent disables DHT, DHT6, PEX, and LPD discovery regardless of broader settings.',
torrentEncryptionPolicyHint: 'Applied when this Torrent starts or retries. Choose one policy so the handshake and payload encryption settings stay consistent.',
torrentEncryptionDisabled: 'Disabled',
torrentEncryptionRequireCrypto: 'Require obfuscated handshake',
@@ -819,6 +848,8 @@ const common = {
torrentDhtDescription: 'Find peers without relying only on trackers. Disabling this also disables UDP tracker support.',
torrentDht6: 'IPv6 DHT',
torrentDht6Description: 'Use IPv6 for distributed peer discovery when the network provides a usable IPv6 path.',
torrentIpv6Enabled: 'Enable IPv6 for Torrent networking',
torrentIpv6EnabledDescription: 'Keep IPv6 available to BitTorrent, DHT, and peer discovery. Disabling this overrides IPv6 DHT even when its preference remains enabled.',
torrentPex: 'Peer Exchange (PEX)',
torrentPexDescription: 'Allow connected peers to share additional peer addresses.',
torrentLpd: 'Local Peer Discovery (LPD)',
@@ -834,6 +865,8 @@ const common = {
torrentMaxConcurrentSeedsDescription: 'Maximum number of Torrents Firelink lets seed at once when separate capacity is enabled.',
torrentListenPort: 'TCP peer ports',
torrentListenPortDescription: 'TCP ports for incoming BitTorrent peer connections. Leave blank for Aria2s default range.',
torrentBindAddress: 'Torrent bind address',
torrentBindAddressDescription: 'Optional local IPv4 or IPv6 address for Aria2 sockets. Invalid addresses are rejected; changes apply after restart.',
torrentDhtListenPort: 'UDP/DHT ports',
torrentDhtListenPortDescription: 'UDP ports for DHT and UDP trackers. Leave blank for Aria2s default range.',
torrentExternalIp: 'External IP address',
@@ -855,6 +888,8 @@ const common = {
torrentResourceLimits: 'BitTorrent resource limits',
torrentMaxOpenFiles: 'Maximum open Torrent files',
torrentMaxOpenFilesDescription: 'Global Aria2 limit for files open at once in multi-file Torrents. Lower values reduce file-descriptor use; the default is 100. Changes apply to new Torrents without restarting Aria2, and this does not raise your operating system limit.',
aria2DiskCache: 'Aria2 disk cache',
aria2DiskCacheDescription: 'Cache size for Aria2, using 0 or a positive value such as 16M. Accepts K/M values up to 1024M and applies after restart.',
torrentMaxOpenFilesUpdateFailed: 'Could not apply the Torrent open-file limit: {{detail}}',
torrentOverallUploadLimit: 'Overall Aria2 upload limit',
torrentOverallUploadLimitDescription: 'Caps combined Aria2 upload traffic, primarily active Torrent seeding in Firelink. Leave blank for unlimited; the value is applied live and restored when Firelink restarts.',
+35
View File
@@ -89,6 +89,7 @@ const fa = {
queued: 'در صف',
downloading: 'در حال دانلود',
processing: 'در حال پردازش',
verifying: 'در حال بررسی صحت',
seeding: 'در حال اشتراک‌گذاری',
waitingToSeed: 'در انتظار اشتراک‌گذاری',
paused: 'متوقف‌شده',
@@ -251,6 +252,8 @@ const fa = {
torrentTrackerIntervalInvalid: 'فاصله Tracker باید عددی صحیح بین ۰ تا ۶۰۴۸۰۰ ثانیه باشد',
torrentVerifyIntegrity: 'بررسی صحت تورنت',
torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد این تورنت اعمال می‌شود. ممکن است قطعه‌ها دوباره بررسی و داده‌های خراب دوباره دانلود شوند؛ در انتقال فعال قابل تغییر نیست.',
torrentVerifyNow: 'بررسی صحت اکنون',
torrentVerifyNowLoading: 'در حال بررسی صحت…',
torrentMaxPeers: 'حداکثر همتاهای تورنت',
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
@@ -262,6 +265,11 @@ const fa = {
torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.',
torrentPeerDiagnosticsHint: 'فقط سرعت و وضعیت اتصال نمایش داده می‌شود؛ IP، پورت، شناسه و بیت‌فیلد همتاها ذخیره نمی‌شود.',
torrentFileProgress: 'پیشرفت فایل‌های تورنت',
torrentFileSelection: 'انتخاب فایل‌های تورنت',
torrentFileSelectionHint: 'فایل‌های موردنظر برای دانلود را انتخاب کنید. انتخاب همهٔ فایل‌ها فیلتر را حذف می‌کند؛ حداقل یک فایل باید انتخاب شود.',
torrentFileSelectionRequired: 'حداقل یک فایل تورنت را انتخاب کنید.',
torrentFileSelectionAll: 'انتخاب همه',
torrentFileSelectionClear: 'پاک‌کردن',
torrentFileProgressRefresh: 'تازه‌سازی',
torrentFileProgressLoading: 'در حال دریافت پیشرفت فایل‌ها…',
torrentFileProgressUnavailable: 'پیشرفت فایل هنگام فعال یا متوقف‌بودن تورنت در دسترس است.',
@@ -300,6 +308,27 @@ const fa = {
torrentPrioritizePieceHint: 'سیاست اختیاری پیش‌نمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام می‌توان اندازه‌ای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.',
torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
torrentFileAllocation: 'نحوهٔ تخصیص فایل تورنت',
torrentFileAllocationPrealloc: 'تخصیص از پیش',
torrentFileAllocationNone: 'تخصیص هنگام نیاز',
torrentFileAllocationHint: 'تخصیص از پیش فضای فایل‌های انتخاب‌شده را قبل از انتقال رزرو می‌کند؛ تخصیص هنگام نیاز این رزرو اولیه را انجام نمی‌دهد.',
torrentDetails: 'جزئیات تورنت',
torrentDetailsLoading: 'در حال دریافت جزئیات تورنت…',
torrentDetailsUnavailable: 'جزئیات تورنت در دسترس نیست.',
torrentDetailsDisplayName: 'نام نمایشی',
torrentDetailsInfoHash: 'هش اطلاعات',
torrentDetailsSize: 'حجم کل',
torrentDetailsFiles: 'فایل‌ها',
torrentDetailsPieces: 'قطعه‌ها',
torrentDetailsPrivate: 'خصوصی',
torrentDetailsPrivateYes: 'بله',
torrentDetailsPrivateNo: 'خیر',
torrentDetailsCreated: 'ایجادشده',
torrentDetailsCreator: 'سازنده',
torrentDetailsComment: 'توضیح',
torrentDetailsTrackers: 'ترکرها',
torrentDetailsWebSeeds: 'وب‌سیدهای داخلی',
torrentDetailsPrivateHint: 'این تورنت خصوصی، مستقل از تنظیمات کلی، کشف DHT، DHT6، PEX و LPD را غیرفعال می‌کند.',
torrentEncryptionPolicyHint: 'هنگام شروع یا تلاش مجدد اعمال می‌شود. یک سیاست واحد انتخاب کنید تا تنظیمات handshake و رمزنگاری payload آریا۲ سازگار بمانند.',
torrentEncryptionDisabled: 'غیرفعال',
torrentEncryptionRequireCrypto: 'الزام handshake مبهم‌سازی‌شده',
@@ -819,6 +848,8 @@ const fa = {
torrentDhtDescription: 'همتاها را بدون تکیه صرف بر ترکرها پیدا می‌کند. خاموش کردن آن پشتیبانی از ترکرهای UDP را هم خاموش می‌کند.',
torrentDht6: 'DHT نسخه IPv6',
torrentDht6Description: 'وقتی مسیر IPv6 قابل استفاده باشد، از آن برای کشف توزیع‌شده همتاها استفاده می‌کند.',
torrentIpv6Enabled: 'فعال‌سازی IPv6 برای تورنت',
torrentIpv6EnabledDescription: 'IPv6 را برای BitTorrent، DHT و کشف همتاها فعال نگه می‌دارد. غیرفعال‌کردن آن IPv6 DHT را خاموش می‌کند.',
torrentPex: 'تبادل همتا (PEX)',
torrentPexDescription: 'به همتاهای متصل اجازه می‌دهد آدرس همتاهای بیشتری را به اشتراک بگذارند.',
torrentLpd: 'کشف همتای محلی (LPD)',
@@ -834,6 +865,8 @@ const fa = {
torrentMaxConcurrentSeedsDescription: 'وقتی ظرفیت جداگانه فعال است، حداکثر تعداد تورنت‌هایی که Firelink هم‌زمان سید می‌کند.',
torrentListenPort: 'پورت‌های همتای TCP',
torrentListenPortDescription: 'پورت‌های TCP برای اتصال‌های ورودی همتاهای بیت‌تورنت. برای محدوده پیش‌فرض Aria2 خالی بگذارید.',
torrentBindAddress: 'نشانی اتصال تورنت',
torrentBindAddressDescription: 'نشانی محلی اختیاری IPv4 یا IPv6 برای سوکت‌های Aria2. نشانی نامعتبر رد می‌شود و تغییر پس از راه‌اندازی مجدد اعمال می‌شود.',
torrentDhtListenPort: 'پورت‌های UDP/DHT',
torrentDhtListenPortDescription: 'پورت‌های UDP برای DHT و ترکرهای UDP. برای محدوده پیش‌فرض Aria2 خالی بگذارید.',
torrentExternalIp: 'آدرس IP خارجی',
@@ -855,6 +888,8 @@ const fa = {
torrentResourceLimits: 'محدودیت منابع بیت‌تورنت',
torrentMaxOpenFiles: 'حداکثر فایل‌های باز تورنت',
torrentMaxOpenFilesDescription: 'حداکثر سراسری Aria2 برای تعداد فایل‌های هم‌زمان باز در تورنت‌های چندفایلی. مقدار کمتر مصرف file descriptor را کم می‌کند؛ پیش‌فرض ۱۰۰ است. تغییرات برای تورنت‌های جدید و بدون راه‌اندازی مجدد Aria2 اعمال می‌شوند و محدودیت سیستم‌عامل را افزایش نمی‌دهند.',
aria2DiskCache: 'کش دیسک Aria2',
aria2DiskCacheDescription: 'اندازهٔ کش Aria2؛ صفر یا مقداری مانند 16M وارد کنید. مقادیر K/M تا 1024M پذیرفته می‌شوند و پس از راه‌اندازی مجدد اعمال می‌شوند.',
torrentMaxOpenFilesUpdateFailed: 'اعمال محدودیت فایل‌های باز تورنت ممکن نشد: {{detail}}',
torrentOverallUploadLimit: 'محدودیت کلی آپلود Aria2',
torrentOverallUploadLimitDescription: 'سرعت کلی آپلود Aria2 را محدود می‌کند؛ در Firelink این مقدار عمدتاً برای سیدینگ تورنت‌هاست. برای نامحدود بودن خالی بگذارید؛ مقدار جدید زنده اعمال می‌شود و پس از راه‌اندازی مجدد Firelink برمی‌گردد.',
+35
View File
@@ -89,6 +89,7 @@ const he = {
queued: 'בתור',
downloading: 'מוריד',
processing: 'מעבד',
verifying: 'מאמת',
seeding: 'משתף',
waitingToSeed: 'ממתין לשיתוף',
paused: 'מושהה',
@@ -251,6 +252,8 @@ const he = {
torrentTrackerIntervalInvalid: 'מרווח ה-Tracker חייב להיות מספר שלם בין 0 ל-604800 שניות',
torrentVerifyIntegrity: 'אימות תקינות הטורנט',
torrentVerifyIntegrityHint: 'מוחל כשהטורנט מתחיל או מנסה שוב. ייתכן שהחלקים ייבדקו מחדש ונתונים פגומים יורדו שוב; אי אפשר לשנות זאת בהעברה פעילה.',
torrentVerifyNow: 'אמת עכשיו',
torrentVerifyNowLoading: 'מאמת…',
torrentMaxPeers: 'מספר העמיתים המרבי בטורנט',
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
@@ -262,6 +265,11 @@ const he = {
torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.',
torrentPeerDiagnosticsHint: 'מוצגים רק מהירויות ודגלי חיבור; כתובות IP, יציאות, מזהים ושדות ביטים אינם נשמרים.',
torrentFileProgress: 'התקדמות קובצי הטורנט',
torrentFileSelection: 'בחירת קובצי טורנט',
torrentFileSelectionHint: 'בחר אילו קבצים להוריד. בחירת כל הקבצים מסירה את הסינון; יש להשאיר לפחות קובץ אחד.',
torrentFileSelectionRequired: 'בחר לפחות קובץ טורנט אחד.',
torrentFileSelectionAll: 'בחר הכול',
torrentFileSelectionClear: 'נקה',
torrentFileProgressRefresh: 'רענון',
torrentFileProgressLoading: 'טוען את התקדמות הקבצים…',
torrentFileProgressUnavailable: 'התקדמות הקבצים זמינה כשהטורנט פעיל או מושהה.',
@@ -300,6 +308,27 @@ const he = {
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.',
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
torrentFileAllocation: 'הקצאת קובצי Torrent',
torrentFileAllocationPrealloc: 'הקצאה מראש',
torrentFileAllocationNone: 'הקצאה לפי הצורך',
torrentFileAllocationHint: 'הקצאה מראש שומרת מקום לקבצים לפני ההעברה; הקצאה לפי הצורך נמנעת מהשמירה הראשונית.',
torrentDetails: 'פרטי טורנט',
torrentDetailsLoading: 'טוען פרטי טורנט…',
torrentDetailsUnavailable: 'פרטי הטורנט אינם זמינים.',
torrentDetailsDisplayName: 'שם תצוגה',
torrentDetailsInfoHash: 'גיבוב מידע',
torrentDetailsSize: 'גודל כולל',
torrentDetailsFiles: 'קבצים',
torrentDetailsPieces: 'חלקים',
torrentDetailsPrivate: 'פרטי',
torrentDetailsPrivateYes: 'כן',
torrentDetailsPrivateNo: 'לא',
torrentDetailsCreated: 'נוצר',
torrentDetailsCreator: 'יוצר',
torrentDetailsComment: 'הערה',
torrentDetailsTrackers: 'עוקבים',
torrentDetailsWebSeeds: 'זרעי Web משובצים',
torrentDetailsPrivateHint: 'טורנט פרטי זה משבית גילוי DHT, DHT6, PEX ו-LPD ללא קשר להגדרות הכלליות.',
torrentEncryptionPolicyHint: 'מוחלת כשה-Torrent מתחיל או מנסה שוב. בחרו מדיניות אחת כדי לשמור על הגדרות handshake והצפנת payload עקביות.',
torrentEncryptionDisabled: 'מושבתת',
torrentEncryptionRequireCrypto: 'דרישת handshake מוסווה',
@@ -819,6 +848,8 @@ const he = {
torrentDhtDescription: 'מאתר עמיתים בלי להסתמך רק על מעקבים. השבתה מכבה גם תמיכה במעקבי UDP.',
torrentDht6: 'DHT של IPv6',
torrentDht6Description: 'משתמש ב-IPv6 לגילוי מבוזר של עמיתים כשיש נתיב IPv6 זמין.',
torrentIpv6Enabled: 'הפעלת IPv6 עבור טורנטים',
torrentIpv6EnabledDescription: 'משאיר את IPv6 זמין עבור BitTorrent, DHT וגילוי עמיתים. השבתה זו מכבה גם IPv6 DHT.',
torrentPex: 'החלפת עמיתים (PEX)',
torrentPexDescription: 'מאפשר לעמיתים מחוברים לשתף כתובות של עמיתים נוספים.',
torrentLpd: 'גילוי עמיתים מקומיים (LPD)',
@@ -834,6 +865,8 @@ const he = {
torrentMaxConcurrentSeedsDescription: 'מספר הטורנטים המרבי ש-Firelink יזריע בו-זמנית כשהקיבולת הנפרדת פעילה.',
torrentListenPort: 'יציאות עמיתי TCP',
torrentListenPortDescription: 'יציאות TCP לחיבורי עמיתים נכנסים של BitTorrent. השאר ריק כדי להשתמש בטווח ברירת המחדל של Aria2.',
torrentBindAddress: 'כתובת קישור לטורנטים',
torrentBindAddressDescription: 'כתובת IPv4 או IPv6 מקומית ואופציונלית לשקעי Aria2. כתובות לא תקינות נדחות; השינוי חל לאחר הפעלה מחדש.',
torrentDhtListenPort: 'יציאות UDP/DHT',
torrentDhtListenPortDescription: 'יציאות UDP עבור DHT ועוקבי UDP. השאר ריק כדי להשתמש בטווח ברירת המחדל של Aria2.',
torrentExternalIp: 'כתובת IP חיצונית',
@@ -855,6 +888,8 @@ const he = {
torrentResourceLimits: 'מגבלות משאבי BitTorrent',
torrentMaxOpenFiles: 'מספר קובצי Torrent פתוחים מרבי',
torrentMaxOpenFilesDescription: 'מגבלה כללית של Aria2 על מספר הקבצים הפתוחים בו-זמנית בטורנטים מרובי קבצים. ערך נמוך יותר מפחית שימוש ב-file descriptors; ברירת המחדל היא 100. השינויים חלים על טורנטים חדשים ללא הפעלה מחדש של Aria2, ואינם מגדילים את מגבלת מערכת ההפעלה.',
aria2DiskCache: 'מטמון דיסק של Aria2',
aria2DiskCacheDescription: 'גודל מטמון Aria2: 0 או ערך כמו 16M. ערכי K/M עד 1024M מתקבלים; חל לאחר הפעלה מחדש.',
torrentMaxOpenFilesUpdateFailed: 'לא ניתן להחיל את מגבלת הקבצים הפתוחים של Torrent: {{detail}}',
torrentOverallUploadLimit: 'מגבלת העלאה כוללת של Aria2',
torrentOverallUploadLimitDescription: 'מגבילה את מהירות ההעלאה המשולבת של Aria2, בעיקר עבור העלאת טורנטים פעילים ב-Firelink. השאר ריק ללא הגבלה; הערך מוחל מיד ומשוחזר לאחר הפעלה מחדש של Firelink.',
+35
View File
@@ -89,6 +89,7 @@ const ru = {
queued: 'В очереди',
downloading: 'Загрузка',
processing: 'Обработка',
verifying: 'Проверка',
seeding: 'Раздача',
waitingToSeed: 'Ожидание раздачи',
paused: 'Приостановлено',
@@ -251,6 +252,8 @@ const ru = {
torrentTrackerIntervalInvalid: 'Интервал трекера должен быть целым числом от 0 до 604800 секунд',
torrentVerifyIntegrity: 'Проверять целостность торрента',
torrentVerifyIntegrityHint: 'Применяется при запуске или повторной попытке. Может повторно проверить части и скачать повреждённые данные; во время активной передачи изменить нельзя.',
torrentVerifyNow: 'Проверить сейчас',
torrentVerifyNowLoading: 'Проверка…',
torrentMaxPeers: 'Максимум пиров торрента',
torrentPeerSpeedLimit: 'Порог скорости пиров',
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
@@ -262,6 +265,11 @@ const ru = {
torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.',
torrentPeerDiagnosticsHint: 'Показываются только скорости и флаги соединения; IP-адреса, порты, идентификаторы и битовые поля не сохраняются.',
torrentFileProgress: 'Прогресс файлов торрента',
torrentFileSelection: 'Выбор файлов торрента',
torrentFileSelectionHint: 'Выберите файлы для загрузки. Выбор всех файлов снимает фильтр; должен остаться хотя бы один файл.',
torrentFileSelectionRequired: 'Выберите хотя бы один файл торрента.',
torrentFileSelectionAll: 'Выбрать все',
torrentFileSelectionClear: 'Очистить',
torrentFileProgressRefresh: 'Обновить',
torrentFileProgressLoading: 'Загрузка прогресса файлов…',
torrentFileProgressUnavailable: 'Прогресс файлов доступен, пока торрент активен или приостановлен.',
@@ -300,7 +308,28 @@ const ru = {
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.',
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
torrentEncryptionPolicy: 'Политика шифрования Torrent',
torrentFileAllocation: 'Выделение места для файлов Torrent',
torrentFileAllocationPrealloc: 'Предварительное выделение',
torrentFileAllocationNone: 'Выделять по мере необходимости',
torrentFileAllocationHint: 'Предварительное выделение резервирует место до передачи; выделение по мере необходимости не делает начальное резервирование.',
torrentEncryptionPolicyHint: 'Применяется при запуске или повторной попытке Torrent. Выберите одну политику, чтобы параметры handshake и шифрования payload оставались согласованными.',
torrentDetails: 'Сведения о Torrent',
torrentDetailsLoading: 'Загрузка сведений о Torrent…',
torrentDetailsUnavailable: 'Сведения о Torrent недоступны.',
torrentDetailsDisplayName: 'Отображаемое имя',
torrentDetailsInfoHash: 'Инфохеш',
torrentDetailsSize: 'Общий размер',
torrentDetailsFiles: 'Файлы',
torrentDetailsPieces: 'Части',
torrentDetailsPrivate: 'Приватный',
torrentDetailsPrivateYes: 'Да',
torrentDetailsPrivateNo: 'Нет',
torrentDetailsCreated: 'Создан',
torrentDetailsCreator: 'Создатель',
torrentDetailsComment: 'Комментарий',
torrentDetailsTrackers: 'Трекеры',
torrentDetailsWebSeeds: 'Встроенные веб-сиды',
torrentDetailsPrivateHint: 'Этот приватный Torrent отключает обнаружение через DHT, DHT6, PEX и LPD независимо от общих настроек.',
torrentEncryptionDisabled: 'Отключено',
torrentEncryptionRequireCrypto: 'Требовать зашифрованное рукопожатие',
torrentEncryptionForceEncryption: 'Принудительно шифровать payload (ARC4)',
@@ -819,6 +848,8 @@ const ru = {
torrentDhtDescription: 'Ищет пиры не только через трекеры. Отключение также отключает поддержку UDP-трекеров.',
torrentDht6: 'DHT по IPv6',
torrentDht6Description: 'Использует IPv6 для распределённого поиска пиров, если доступен рабочий IPv6-маршрут.',
torrentIpv6Enabled: 'Использовать IPv6 для торрентов',
torrentIpv6EnabledDescription: 'Оставляет IPv6 доступным для BitTorrent, DHT и поиска пиров. Отключение также выключает IPv6 DHT.',
torrentPex: 'Обмен пирами (PEX)',
torrentPexDescription: 'Позволяет подключённым пирам передавать адреса дополнительных пиров.',
torrentLpd: 'Локальное обнаружение пиров (LPD)',
@@ -834,6 +865,8 @@ const ru = {
torrentMaxConcurrentSeedsDescription: 'Максимальное число торрентов, которые Firelink раздаёт одновременно при включённой отдельной ёмкости.',
torrentListenPort: 'TCP-порты пиров',
torrentListenPortDescription: 'TCP-порты для входящих соединений BitTorrent. Оставьте пустым, чтобы использовать диапазон Aria2 по умолчанию.',
torrentBindAddress: 'Адрес привязки торрентов',
torrentBindAddressDescription: 'Необязательный локальный IPv4- или IPv6-адрес для сокетов Aria2. Недопустимые адреса отклоняются; применяется после перезапуска.',
torrentDhtListenPort: 'Порты UDP/DHT',
torrentDhtListenPortDescription: 'UDP-порты для DHT и UDP-трекеров. Оставьте пустым, чтобы использовать диапазон Aria2 по умолчанию.',
torrentExternalIp: 'Внешний IP-адрес',
@@ -855,6 +888,8 @@ const ru = {
torrentResourceLimits: 'Ограничения ресурсов BitTorrent',
torrentMaxOpenFiles: 'Максимум открытых файлов Torrent',
torrentMaxOpenFilesDescription: 'Глобальный лимит Aria2 на одновременно открытые файлы в многофайловых торрентах. Меньшие значения снижают расход дескрипторов; по умолчанию 100. Изменения применяются к новым торрентам без перезапуска Aria2 и не повышают лимит операционной системы.',
aria2DiskCache: 'Дисковый кэш Aria2',
aria2DiskCacheDescription: 'Размер кэша Aria2: 0 или значение вроде 16M. Допустимы K/M до 1024M; применяется после перезапуска.',
torrentMaxOpenFilesUpdateFailed: 'Не удалось применить лимит открытых файлов Torrent: {{detail}}',
torrentOverallUploadLimit: 'Общий лимит отдачи Aria2',
torrentOverallUploadLimitDescription: 'Ограничивает суммарную скорость отдачи Aria2; в Firelink это в основном раздача активных торрентов. Оставьте поле пустым для снятия ограничения; значение применяется сразу и восстанавливается после перезапуска Firelink.',
+35
View File
@@ -89,6 +89,7 @@ const uk = {
queued: 'У черзі',
downloading: 'Завантаження',
processing: 'Обробка',
verifying: 'Перевірка',
seeding: 'Роздача',
waitingToSeed: 'Очікування роздачі',
paused: 'Призупинено',
@@ -251,6 +252,8 @@ const uk = {
torrentTrackerIntervalInvalid: 'Інтервал трекера має бути цілим числом від 0 до 604800 секунд',
torrentVerifyIntegrity: 'Перевіряти цілісність торрента',
torrentVerifyIntegrityHint: 'Застосовується під час запуску або повторної спроби. Частини можуть перевірятися повторно, а пошкоджені дані — завантажуватися знову; під час активної передачі змінити не можна.',
torrentVerifyNow: 'Перевірити зараз',
torrentVerifyNowLoading: 'Перевірка…',
torrentMaxPeers: 'Максимум пірів торрента',
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
@@ -262,6 +265,11 @@ const uk = {
torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.',
torrentPeerDiagnosticsHint: 'Показуються лише швидкості та прапорці з’єднання; IP-адреси, порти, ідентифікатори й бітові поля не зберігаються.',
torrentFileProgress: 'Прогрес файлів торрента',
torrentFileSelection: 'Вибір файлів торрента',
torrentFileSelectionHint: 'Виберіть файли для завантаження. Вибір усіх файлів прибирає фільтр; має залишитися хоча б один файл.',
torrentFileSelectionRequired: 'Виберіть хоча б один файл торрента.',
torrentFileSelectionAll: 'Вибрати все',
torrentFileSelectionClear: 'Очистити',
torrentFileProgressRefresh: 'Оновити',
torrentFileProgressLoading: 'Завантаження прогресу файлів…',
torrentFileProgressUnavailable: 'Прогрес файлів доступний, коли торрент активний або призупинений.',
@@ -300,7 +308,28 @@ const uk = {
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.',
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
torrentEncryptionPolicy: 'Політика шифрування Torrent',
torrentFileAllocation: 'Виділення місця для файлів Torrent',
torrentFileAllocationPrealloc: 'Попереднє виділення',
torrentFileAllocationNone: 'Виділяти за потреби',
torrentFileAllocationHint: 'Попереднє виділення резервує місце до передачі; виділення за потреби не робить початкового резервування.',
torrentEncryptionPolicyHint: 'Застосовується під час запуску або повторної спроби Torrent. Виберіть одну політику, щоб параметри handshake і шифрування payload залишалися узгодженими.',
torrentDetails: 'Відомості про Torrent',
torrentDetailsLoading: 'Завантаження відомостей про Torrent…',
torrentDetailsUnavailable: 'Відомості про Torrent недоступні.',
torrentDetailsDisplayName: 'Назва',
torrentDetailsInfoHash: 'Інфохеш',
torrentDetailsSize: 'Загальний розмір',
torrentDetailsFiles: 'Файли',
torrentDetailsPieces: 'Частини',
torrentDetailsPrivate: 'Приватний',
torrentDetailsPrivateYes: 'Так',
torrentDetailsPrivateNo: 'Ні',
torrentDetailsCreated: 'Створено',
torrentDetailsCreator: 'Автор',
torrentDetailsComment: 'Коментар',
torrentDetailsTrackers: 'Трекери',
torrentDetailsWebSeeds: 'Вбудовані веб-сиди',
torrentDetailsPrivateHint: 'Цей приватний Torrent вимикає виявлення через DHT, DHT6, PEX і LPD незалежно від загальних налаштувань.',
torrentEncryptionDisabled: 'Вимкнено',
torrentEncryptionRequireCrypto: 'Вимагати зашифроване рукостискання',
torrentEncryptionForceEncryption: 'Примусово шифрувати payload (ARC4)',
@@ -819,6 +848,8 @@ const uk = {
torrentDhtDescription: 'Шукає пірів не лише через трекери. Вимкнення також вимикає підтримку UDP-трекерів.',
torrentDht6: 'DHT через IPv6',
torrentDht6Description: 'Використовує IPv6 для розподіленого пошуку пірів, якщо доступний робочий маршрут IPv6.',
torrentIpv6Enabled: 'Використовувати IPv6 для торентів',
torrentIpv6EnabledDescription: 'Залишає IPv6 доступним для BitTorrent, DHT і пошуку пірів. Вимкнення також вимикає IPv6 DHT.',
torrentPex: 'Обмін пірами (PEX)',
torrentPexDescription: 'Дозволяє підключеним пірам передавати адреси додаткових пірів.',
torrentLpd: 'Локальний пошук пірів (LPD)',
@@ -834,6 +865,8 @@ const uk = {
torrentMaxConcurrentSeedsDescription: 'Максимальна кількість торентів, які Firelink роздає одночасно за ввімкненої окремої місткості.',
torrentListenPort: 'TCP-порти пірів',
torrentListenPortDescription: 'TCP-порти для вхідних з’єднань BitTorrent. Залиште порожнім, щоб використати типовий діапазон Aria2.',
torrentBindAddress: 'Адреса прив’язки торентів',
torrentBindAddressDescription: 'Необов’язкова локальна IPv4- або IPv6-адреса для сокетів Aria2. Некоректні адреси відхиляються; застосовується після перезапуску.',
torrentDhtListenPort: 'Порти UDP/DHT',
torrentDhtListenPortDescription: 'UDP-порти для DHT і UDP-трекерів. Залиште порожнім, щоб використати типовий діапазон Aria2.',
torrentExternalIp: 'Зовнішня IP-адреса',
@@ -855,6 +888,8 @@ const uk = {
torrentResourceLimits: 'Обмеження ресурсів BitTorrent',
torrentMaxOpenFiles: 'Максимум відкритих файлів Torrent',
torrentMaxOpenFilesDescription: 'Глобальне обмеження Aria2 на одночасно відкриті файли в багатофайлових торрентах. Менші значення зменшують використання дескрипторів; типове значення — 100. Зміни застосовуються до нових торрентів без перезапуску Aria2 і не підвищують обмеження операційної системи.',
aria2DiskCache: 'Дисковий кеш Aria2',
aria2DiskCacheDescription: 'Розмір кешу Aria2: 0 або значення на кшталт 16M. Допустимі K/M до 1024M; застосовується після перезапуску.',
torrentMaxOpenFilesUpdateFailed: 'Не вдалося застосувати обмеження відкритих файлів Torrent: {{detail}}',
torrentOverallUploadLimit: 'Загальне обмеження віддачі Aria2',
torrentOverallUploadLimitDescription: 'Обмежує сумарну швидкість віддачі Aria2; у Firelink це переважно роздача активних торрентів. Залиште поле порожнім без обмеження; значення застосовується одразу й відновлюється після перезапуску Firelink.',
+35
View File
@@ -89,6 +89,7 @@ const zhCN = {
queued: '已排队',
downloading: '下载中',
processing: '处理中',
verifying: '校验中',
seeding: '做种中',
waitingToSeed: '等待做种',
paused: '已暂停',
@@ -251,6 +252,8 @@ const zhCN = {
torrentTrackerIntervalInvalid: 'Tracker 间隔必须是 0 到 604800 秒之间的整数',
torrentVerifyIntegrity: '验证 Torrent 完整性',
torrentVerifyIntegrityHint: '在 Torrent 启动或重试时应用。可能会重新检查分片并重新下载损坏的数据;活动传输期间无法更改。',
torrentVerifyNow: '立即验证',
torrentVerifyNowLoading: '验证中…',
torrentMaxPeers: 'Torrent 最大对等节点数',
torrentPeerSpeedLimit: '对等节点速度阈值',
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
@@ -262,6 +265,11 @@ const zhCN = {
torrentPeerDiagnosticsFailed: '无法读取 Torrent 对等节点诊断。',
torrentPeerDiagnosticsHint: '仅显示速度和连接状态;不会保留对等节点 IP、端口、ID 或位域。',
torrentFileProgress: 'Torrent 文件进度',
torrentFileSelection: 'Torrent 文件选择',
torrentFileSelectionHint: '选择要下载的文件。选择全部文件会移除筛选;至少要保留一个文件。',
torrentFileSelectionRequired: '请至少选择一个 Torrent 文件。',
torrentFileSelectionAll: '全选',
torrentFileSelectionClear: '清除',
torrentFileProgressRefresh: '刷新',
torrentFileProgressLoading: '正在加载文件进度…',
torrentFileProgressUnavailable: 'Torrent 活跃或暂停时可查看文件进度。',
@@ -300,7 +308,28 @@ const zhCN = {
torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。',
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
torrentEncryptionPolicy: 'Torrent 加密策略',
torrentFileAllocation: 'Torrent 文件分配',
torrentFileAllocationPrealloc: '预分配文件',
torrentFileAllocationNone: '按需分配',
torrentFileAllocationHint: '预分配会在传输前为选中文件预留空间;按需分配不会进行初始磁盘预留。',
torrentEncryptionPolicyHint: '在 Torrent 启动或重试时应用。选择单一策略,确保握手和 payload 加密设置保持一致。',
torrentDetails: 'Torrent 详细信息',
torrentDetailsLoading: '正在加载 Torrent 详细信息…',
torrentDetailsUnavailable: 'Torrent 详细信息不可用。',
torrentDetailsDisplayName: '显示名称',
torrentDetailsInfoHash: '信息哈希',
torrentDetailsSize: '总大小',
torrentDetailsFiles: '文件',
torrentDetailsPieces: '分片',
torrentDetailsPrivate: '私有',
torrentDetailsPrivateYes: '是',
torrentDetailsPrivateNo: '否',
torrentDetailsCreated: '创建时间',
torrentDetailsCreator: '创建者',
torrentDetailsComment: '备注',
torrentDetailsTrackers: 'Tracker',
torrentDetailsWebSeeds: '内嵌 Web seed',
torrentDetailsPrivateHint: '此私有 Torrent 会独立于全局设置禁用 DHT、DHT6、PEX 和 LPD 发现。',
torrentEncryptionDisabled: '已禁用',
torrentEncryptionRequireCrypto: '要求加密握手',
torrentEncryptionForceEncryption: '强制加密 payloadARC4',
@@ -819,6 +848,8 @@ const zhCN = {
torrentDhtDescription: '不只依赖 Tracker 查找节点。关闭后也会禁用 UDP Tracker 支持。',
torrentDht6: 'IPv6 分布式哈希表',
torrentDht6Description: '当网络提供可用的 IPv6 路径时,使用 IPv6 进行分布式节点发现。',
torrentIpv6Enabled: '为种子网络启用 IPv6',
torrentIpv6EnabledDescription: '为 BitTorrent、DHT 和节点发现保留 IPv6。禁用后也会关闭 IPv6 DHT。',
torrentPex: '节点交换(PEX',
torrentPexDescription: '允许已连接的节点共享其他节点的地址。',
torrentLpd: '本地节点发现(LPD',
@@ -834,6 +865,8 @@ const zhCN = {
torrentMaxConcurrentSeedsDescription: '启用独立容量后,Firelink 同时做种的 Torrent 数量上限。',
torrentListenPort: 'TCP 节点端口',
torrentListenPortDescription: '用于传入 BitTorrent 节点连接的 TCP 端口。留空以使用 Aria2 的默认范围。',
torrentBindAddress: '种子绑定地址',
torrentBindAddressDescription: '可选的本地 IPv4 或 IPv6 地址,用于 Aria2 套接字。无效地址会被拒绝;重启后生效。',
torrentDhtListenPort: 'UDP/DHT 端口',
torrentDhtListenPortDescription: '用于 DHT 和 UDP 跟踪器的 UDP 端口。留空以使用 Aria2 的默认范围。',
torrentExternalIp: '外部 IP 地址',
@@ -855,6 +888,8 @@ const zhCN = {
torrentResourceLimits: 'BitTorrent 资源限制',
torrentMaxOpenFiles: 'Torrent 最大打开文件数',
torrentMaxOpenFilesDescription: 'Aria2 对多文件 Torrent 同时打开文件数的全局限制。较低的值可减少文件描述符占用;默认值为 100。修改会在不重启 Aria2 的情况下应用于新 Torrent,且不会提高操作系统的限制。',
aria2DiskCache: 'Aria2 磁盘缓存',
aria2DiskCacheDescription: 'Aria2 缓存大小:0 或类似 16M 的值。接受最大 1024M 的 K/M 值,重启后生效。',
torrentMaxOpenFilesUpdateFailed: '无法应用 Torrent 打开文件数限制:{{detail}}',
torrentOverallUploadLimit: 'Aria2 总上传限制',
torrentOverallUploadLimitDescription: '限制 Aria2 的总上传速度,在 Firelink 中主要用于活动 Torrent 做种。留空表示不限速;新值会立即应用,并在 Firelink 重启后恢复。',
+6
View File
@@ -23,6 +23,8 @@ import type { TorrentPeerDiagnostics } from './bindings/TorrentPeerDiagnostics';
import type { TorrentFileProgressSnapshot } from './bindings/TorrentFileProgressSnapshot';
import type { TorrentPieceProgressSnapshot } from './bindings/TorrentPieceProgressSnapshot';
import type { TorrentWebSeed } from './bindings/TorrentWebSeed';
import type { TorrentDetails } from './bindings/TorrentDetails';
import type { TorrentFileSelectionSnapshot } from './bindings/TorrentFileSelectionSnapshot';
type CommandMap = {
fetch_metadata: {
@@ -84,6 +86,10 @@ type CommandMap = {
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
get_torrent_file_progress: { args: { id: string }; result: TorrentFileProgressSnapshot };
get_torrent_piece_progress: { args: { id: string }; result: TorrentPieceProgressSnapshot };
get_torrent_file_selection: { args: { id: string }; result: TorrentFileSelectionSnapshot };
set_torrent_file_selection: { args: { id: string; selected_indices: number[] | null }; result: TorrentFileSelectionSnapshot };
get_torrent_details: { args: { id: string }; result: TorrentDetails };
verify_torrent_data: { args: { id: string }; result: void };
get_torrent_web_seeds: { args: { id: string }; result: TorrentWebSeed[] };
set_torrent_web_seeds: { args: { id: string; seeds: TorrentWebSeed[] }; result: TorrentWebSeed[] };
set_torrent_max_open_files: { args: { max_open_files: number }; result: void };
+16 -6
View File
@@ -45,14 +45,14 @@ 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', 'seeding'].includes(current.status)) {
if (!['downloading', 'processing', 'verifying', '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' || current.status === 'seeding') {
if (current.status === 'downloading' || current.status === 'processing' || current.status === 'verifying' || current.status === 'seeding') {
updates.fraction = payload.fraction;
updates.speed = current.status === 'seeding'
? payload.upload_speed ?? '-'
@@ -121,7 +121,7 @@ const startDownloadListeners = async () => {
return;
}
if (status === 'downloading' || status === 'processing' ||
status === 'seeding' || status === 'waitingToSeed' ||
status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' ||
status === 'completed' || status === 'failed') {
clearDownloadControlIntent(payload.id, 'resume');
}
@@ -173,7 +173,7 @@ const startDownloadListeners = async () => {
: {})
} : {}),
...(payload.error ? { lastError: payload.error } : {}),
...((status === 'downloading' || status === 'retrying')
...((status === 'downloading' || status === 'verifying' || status === 'retrying')
? { lastTry: new Date().toISOString() }
: {})
};
@@ -189,10 +189,20 @@ const startDownloadListeners = async () => {
updates.fileName = payload.fileName;
updates.category = categoryForFileName(payload.fileName);
}
if (status !== 'downloading') {
if (status !== 'downloading' && status !== 'verifying') {
updates.speed = '-';
updates.eta = '-';
}
if (
current.torrentVerifyOnly === true &&
['ready', 'staged', 'paused', 'completed', 'failed'].includes(status)
) {
// Verification is a maintenance lifecycle layered over the existing
// row. Clear its markers once Aria2 has reached the restored terminal
// state so restart cannot replay verification indefinitely.
updates.torrentVerifyOnly = undefined;
updates.torrentVerifyRestoreStatus = undefined;
}
mainStore.updateDownload(payload.id, updates);
if (status === 'completed' || status === 'failed' || status === 'paused' || status === 'seeding' || status === 'waitingToSeed') {
@@ -205,7 +215,7 @@ const startDownloadListeners = async () => {
: { pendingOrder: [...state.pendingOrder, payload.id] });
}
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying') {
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying') {
mainStore.registerBackendIds([payload.id]);
} else if (status === 'completed' || status === 'failed') {
mainStore.unregisterBackendIds([payload.id]);
+27
View File
@@ -167,6 +167,33 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().downloads[0].torrentRemoveUnselectedFile).toBe(false);
});
it('detaches a paused backend lifecycle even when the frontend registration set is stale', async () => {
useDownloadStore.setState({
downloads: [{
id: 'paused-stale-registration',
url: 'magnet:?xt=urn:btih:abc',
fileName: 'torrent',
status: 'paused',
category: 'Other',
dateAdded: '',
isTorrent: true,
torrentFileIndices: [1]
}] as any[],
backendRegisteredIds: new Set()
});
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined as never);
await useDownloadStore.getState().applyProperties('paused-stale-registration', {
torrentFileIndices: [2]
});
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'detach_download_for_reconfigure',
{ id: 'paused-stale-registration' }
);
expect(useDownloadStore.getState().downloads[0].torrentFileIndices).toEqual([2]);
});
it('replaces stale media intent when an appended handoff reuses a URL', () => {
useDownloadStore.getState().openAddModalWithUrls(
'https://example.com/file.bin', '', '', '', '', true
+37 -12
View File
@@ -9,7 +9,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -362,6 +362,9 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
torrent_remove_unselected_file: item.torrentRemoveUnselectedFile,
torrent_encryption_policy: item.torrentEncryptionPolicy || undefined,
torrent_file_allocation: item.torrentFileAllocation || undefined,
torrent_verify_only: item.torrentVerifyOnly,
torrent_verify_restore_status: item.torrentVerifyRestoreStatus,
lifecycle_generation: lifecycleGeneration.toString(),
};
@@ -690,6 +693,16 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
: undefined;
const rawEncryptionPolicy = download.torrentEncryptionPolicy as unknown;
const normalizedEncryptionPolicy = normalizeTorrentEncryptionPolicy(rawEncryptionPolicy);
const rawFileAllocation = download.torrentFileAllocation as unknown;
const normalizedFileAllocation = normalizeTorrentFileAllocation(rawFileAllocation);
const rawVerifyOnly = download.torrentVerifyOnly as unknown;
const normalizedVerifyOnly = rawVerifyOnly === true ? true : undefined;
const rawVerifyRestoreStatus = download.torrentVerifyRestoreStatus as unknown;
const normalizedVerifyRestoreStatus = normalizedVerifyOnly === true
&& typeof rawVerifyRestoreStatus === 'string'
&& ['paused', 'failed', 'completed'].includes(rawVerifyRestoreStatus)
? rawVerifyRestoreStatus
: undefined;
const normalizedOptions = rawSeedRemaining !== normalizedSeedRemaining ||
rawWebSeeds !== normalizedWebSeeds ||
rawMaxPeers !== normalizedMaxPeers ||
@@ -703,7 +716,10 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
rawStopTimeout !== normalizedStopTimeout ||
rawPrioritizePiece !== normalizedPrioritizePiece ||
rawRemoveUnselectedFile !== normalizedRemoveUnselectedFile ||
rawEncryptionPolicy !== normalizedEncryptionPolicy
rawEncryptionPolicy !== normalizedEncryptionPolicy ||
rawFileAllocation !== normalizedFileAllocation ||
rawVerifyOnly !== normalizedVerifyOnly ||
rawVerifyRestoreStatus !== normalizedVerifyRestoreStatus
? {
...download,
torrentSeedRemaining: normalizedSeedRemaining,
@@ -719,7 +735,10 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
torrentStopTimeout: normalizedStopTimeout,
torrentPrioritizePiece: normalizedPrioritizePiece,
torrentRemoveUnselectedFile: normalizedRemoveUnselectedFile,
torrentEncryptionPolicy: normalizedEncryptionPolicy
torrentEncryptionPolicy: normalizedEncryptionPolicy,
torrentFileAllocation: normalizedFileAllocation,
torrentVerifyOnly: normalizedVerifyOnly,
torrentVerifyRestoreStatus: normalizedVerifyRestoreStatus
}
: download;
@@ -940,7 +959,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
&& normalizedUpdates.torrentRemoveUnselectedFile === false
&& item.torrentRemoveUnselectedFile !== false;
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'seeding' || item.status === 'retrying') {
if (item.status === 'downloading' || item.status === 'processing' || item.status === 'verifying' || item.status === 'seeding' || item.status === 'retrying') {
throw new Error(i18n.t($ => $.downloadTable.transferActive));
}
@@ -974,15 +993,18 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}
}
} else if (item.status === 'paused') {
if (isRegistered) {
try {
await invoke('detach_download_for_reconfigure', { id });
} catch (e) {
console.error("Failed to detach for reconfigure:", e);
throw e; // Preserve old properties if detach fails
}
state.unregisterBackendIds([id]);
// The frontend deliberately removes paused rows from
// backendRegisteredIds, but the backend keeps a paused Aria2 GID and
// its old payload alive for an in-place resume. Any property change,
// especially Torrent selection/output changes, must retire that
// lifecycle or resume will silently use stale daemon options.
try {
await invoke('detach_download_for_reconfigure', { id });
} catch (e) {
console.error("Failed to detach for reconfigure:", e);
throw e; // Preserve old properties if detach fails
}
if (isRegistered) state.unregisterBackendIds([id]);
if (disablingTorrentRemoval) {
await invoke('clear_torrent_removal_paths', { id });
}
@@ -2288,6 +2310,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
torrent_prioritize_piece: item.torrentPrioritizePiece || undefined,
torrent_remove_unselected_file: item.torrentRemoveUnselectedFile,
torrent_encryption_policy: item.torrentEncryptionPolicy || undefined,
torrent_file_allocation: item.torrentFileAllocation || undefined,
torrent_verify_only: item.torrentVerifyOnly,
torrent_verify_restore_status: item.torrentVerifyRestoreStatus,
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
});
}
+25
View File
@@ -254,6 +254,7 @@ export interface SettingsState {
torrentDhtMessageTimeout: number;
torrentSeparateSeedSlots: boolean;
torrentMaxConcurrentSeeds: number;
torrentIpv6Enabled: boolean;
torrentListenPort: string;
torrentDhtListenPort: string;
torrentExternalIp: string;
@@ -263,6 +264,8 @@ export interface SettingsState {
torrentLpdInterface: string;
torrentPeerIdPrefix: string;
torrentPeerAgent: string;
torrentBindAddress: string;
aria2DiskCache: string;
customUserAgent: string;
askWhereToSaveEachFile: boolean;
preventsSleepWhileDownloading: boolean;
@@ -322,6 +325,7 @@ export interface SettingsState {
setTorrentDhtMessageTimeout: (value: number) => void;
setTorrentSeparateSeedSlots: (enabled: boolean) => void;
setTorrentMaxConcurrentSeeds: (value: number) => void;
setTorrentIpv6Enabled: (enabled: boolean) => void;
setTorrentListenPort: (value: string) => void;
setTorrentDhtListenPort: (value: string) => void;
setTorrentExternalIp: (value: string) => void;
@@ -331,6 +335,8 @@ export interface SettingsState {
setTorrentLpdInterface: (value: string) => void;
setTorrentPeerIdPrefix: (value: string) => void;
setTorrentPeerAgent: (value: string) => void;
setTorrentBindAddress: (value: string) => void;
setAria2DiskCache: (value: string) => void;
setCustomUserAgent: (userAgent: string) => void;
setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
@@ -416,6 +422,7 @@ export const useSettingsStore = create<SettingsState>()(
torrentDhtMessageTimeout: DEFAULT_TORRENT_DHT_MESSAGE_TIMEOUT,
torrentSeparateSeedSlots: false,
torrentMaxConcurrentSeeds: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS,
torrentIpv6Enabled: true,
torrentListenPort: '',
torrentDhtListenPort: '',
torrentExternalIp: '',
@@ -425,6 +432,8 @@ export const useSettingsStore = create<SettingsState>()(
torrentLpdInterface: '',
torrentPeerIdPrefix: '',
torrentPeerAgent: '',
torrentBindAddress: '',
aria2DiskCache: '16M',
customUserAgent: '',
askWhereToSaveEachFile: false,
preventsSleepWhileDownloading: true,
@@ -549,6 +558,8 @@ export const useSettingsStore = create<SettingsState>()(
setTorrentLpdInterface: (torrentLpdInterface) => set({ torrentLpdInterface }),
setTorrentPeerIdPrefix: (torrentPeerIdPrefix) => set({ torrentPeerIdPrefix }),
setTorrentPeerAgent: (torrentPeerAgent) => set({ torrentPeerAgent }),
setTorrentBindAddress: (torrentBindAddress) => set({ torrentBindAddress }),
setAria2DiskCache: (aria2DiskCache) => set({ aria2DiskCache }),
setTorrentMaxOpenFiles: (value) => {
const normalized = normalizeTorrentMaxOpenFiles(value);
if (normalized === undefined) {
@@ -578,6 +589,7 @@ export const useSettingsStore = create<SettingsState>()(
? value
: DEFAULT_TORRENT_MAX_CONCURRENT_SEEDS
}),
setTorrentIpv6Enabled: (torrentIpv6Enabled) => set({ torrentIpv6Enabled }),
setCustomUserAgent: (customUserAgent) => set({ customUserAgent }),
setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }),
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
@@ -768,6 +780,7 @@ export const useSettingsStore = create<SettingsState>()(
torrentDhtMessageTimeout: state.torrentDhtMessageTimeout,
torrentSeparateSeedSlots: state.torrentSeparateSeedSlots,
torrentMaxConcurrentSeeds: state.torrentMaxConcurrentSeeds,
torrentIpv6Enabled: state.torrentIpv6Enabled,
torrentListenPort: state.torrentListenPort,
torrentDhtListenPort: state.torrentDhtListenPort,
torrentExternalIp: state.torrentExternalIp,
@@ -777,6 +790,8 @@ export const useSettingsStore = create<SettingsState>()(
torrentLpdInterface: state.torrentLpdInterface,
torrentPeerIdPrefix: state.torrentPeerIdPrefix,
torrentPeerAgent: state.torrentPeerAgent,
torrentBindAddress: state.torrentBindAddress,
aria2DiskCache: state.aria2DiskCache,
customUserAgent: state.customUserAgent,
askWhereToSaveEachFile: state.askWhereToSaveEachFile,
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
@@ -840,6 +855,10 @@ export const useSettingsStore = create<SettingsState>()(
&& persisted.torrentMaxConcurrentSeeds <= 64
? persisted.torrentMaxConcurrentSeeds
: currentState.torrentMaxConcurrentSeeds,
torrentIpv6Enabled: persistedBoolean(
persisted.torrentIpv6Enabled,
currentState.torrentIpv6Enabled
),
torrentListenPort: typeof persisted.torrentListenPort === 'string'
? persisted.torrentListenPort
: currentState.torrentListenPort,
@@ -867,6 +886,12 @@ export const useSettingsStore = create<SettingsState>()(
torrentPeerAgent: typeof persisted.torrentPeerAgent === 'string'
? persisted.torrentPeerAgent
: currentState.torrentPeerAgent,
torrentBindAddress: typeof persisted.torrentBindAddress === 'string'
? persisted.torrentBindAddress
: currentState.torrentBindAddress,
aria2DiskCache: typeof persisted.aria2DiskCache === 'string'
? persisted.aria2DiskCache
: currentState.aria2DiskCache,
sidebarPosition: isAllowedSetting(SIDEBAR_POSITION_VALUES, persisted.sidebarPosition)
? persisted.sidebarPosition
: currentState.sidebarPosition,
+2 -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', 'seeding', 'processing', 'retrying'] as const) {
for (const status of ['staged', 'queued', 'downloading', 'seeding', 'processing', 'verifying', 'retrying'] as const) {
expect(canPauseDownload(status)).toBe(true);
}
for (const status of ['queued', 'downloading', 'processing', 'retrying'] as const) {
@@ -40,6 +40,7 @@ describe('download action policy', () => {
expect(getPauseResumeAction('queued')).toBe('pause');
expect(getPauseResumeAction('downloading')).toBe('pause');
expect(getPauseResumeAction('processing')).toBe('pause');
expect(getPauseResumeAction('verifying')).toBe('pause');
expect(getPauseResumeAction('seeding')).toBe('pause');
expect(getPauseResumeAction('retrying')).toBe('pause');
expect(getPauseResumeAction('paused')).toBe('resume');
+2 -1
View File
@@ -15,6 +15,7 @@ const PAUSABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'seeding',
'waitingToSeed',
'processing',
'verifying',
'retrying',
]);
@@ -66,7 +67,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 === 'seeding' || status === 'waitingToSeed' || status === 'retrying';
status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || 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 'verifying':
return 'download-status-processing';
case 'seeding':
return 'download-status-seeding';
case 'queued':
+1
View File
@@ -22,6 +22,7 @@ const isFreshDownloadStatus = (status: DownloadItem['status']): boolean =>
status === 'downloading' ||
status === 'seeding' ||
status === 'processing' ||
status === 'verifying' ||
status === 'retrying';
const hasPositiveProgress = (download: DownloadItem): boolean =>
+8
View File
@@ -56,6 +56,14 @@ describe('download persistence progress snapshots', () => {
expect(persisted.totalIsEstimate).toBe(false);
}
);
it('does not persist verification byte counters across restart', () => {
const persisted = redactDownloadForPersistence(item('verifying'));
expect(persisted.downloadedBytes).toBeUndefined();
expect(persisted.totalBytes).toBeUndefined();
expect(persisted.totalIsEstimate).toBeUndefined();
});
});
describe('Torrent tracker input validation', () => {
+8 -1
View File
@@ -30,6 +30,7 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'queued',
'downloading',
'processing',
'verifying',
'seeding',
'waitingToSeed',
'retrying',
@@ -40,7 +41,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 === 'seeding' || status === 'retrying';
status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'retrying';
export const DOWNLOAD_CONNECTIONS_MIN = 1;
export const DOWNLOAD_CONNECTIONS_MAX = 16;
@@ -66,6 +67,11 @@ export const normalizeTorrentEncryptionPolicy = (
return undefined;
};
export type TorrentFileAllocation = 'prealloc' | 'none';
export const normalizeTorrentFileAllocation = (value: unknown): TorrentFileAllocation | undefined =>
value === 'prealloc' || value === 'none' ? value : undefined;
export const MAX_TORRENT_TRACKER_TIMEOUT = 604800;
export const MAX_TORRENT_TRACKER_INTERVAL = 604800;
export const DEFAULT_TORRENT_MAX_OPEN_FILES = 100;
@@ -456,6 +462,7 @@ export const isMediaUrl = (rawUrl: string): boolean => {
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
const VOLATILE_PROGRESS_STATUSES = new Set([
'downloading',
'verifying',
'seeding'
]);