feat(torrent): complete lifecycle controls and diagnostics

- add durable Torrent telemetry, availability, sharing, relocation, and web-seed workflows\n- fence queue ownership, lifecycle recovery, persistence, and native control races\n- add localized UI, generated bindings, regression coverage, and smoke validation
This commit is contained in:
NimBold
2026-08-04 01:18:24 +03:30
parent 55a905df14
commit 579a8f7f80
33 changed files with 3505 additions and 139 deletions
+2 -1
View File
@@ -14,6 +14,7 @@
"log:default",
"notification:default",
"notification:allow-is-permission-granted",
"clipboard-manager:allow-read-text"
"clipboard-manager:allow-read-text",
"clipboard-manager:allow-write-text"
]
}
+53
View File
@@ -83,6 +83,8 @@ pub enum DownloadStatus {
/// Aria2 is verifying already-present Torrent data before transfer or
/// after an explicit integrity check.
Verifying,
/// Firelink is moving owned Torrent data between managed destinations.
Moving,
}
impl DownloadStatus {
@@ -100,6 +102,7 @@ impl DownloadStatus {
Self::Queued => "queued",
Self::Retrying => "retrying",
Self::Verifying => "verifying",
Self::Moving => "moving",
}
}
}
@@ -219,10 +222,28 @@ pub struct DownloadItem {
#[ts(optional)]
pub torrent_seed_remaining: Option<f64>,
#[serde(default)]
#[ts(optional, type = "number")]
pub torrent_uploaded_bytes: Option<u64>,
#[serde(default)]
#[ts(optional, type = "number")]
pub torrent_seeded_seconds: Option<u64>,
#[serde(default)]
#[ts(optional)]
pub torrent_relocation_check_pending: Option<bool>,
#[serde(default)]
#[ts(optional)]
pub torrent_move_destination: Option<String>,
#[serde(default)]
#[ts(optional)]
pub torrent_move_restore_status: Option<DownloadStatus>,
#[serde(default)]
#[ts(optional)]
pub torrent_web_seeds: Option<Vec<TorrentWebSeed>>,
#[serde(default)]
#[ts(optional)]
pub torrent_web_seeds_native: Option<Vec<TorrentWebSeed>>,
#[serde(default)]
#[ts(optional)]
pub torrent_upload_limit: Option<String>,
#[serde(default)]
#[ts(optional)]
@@ -372,6 +393,38 @@ pub struct TorrentDetails {
pub web_seeds: Vec<String>,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentAvailabilityBucket {
#[ts(type = "number")]
pub minimum_copies: u16,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentAvailabilitySnapshot {
#[ts(type = "number")]
pub piece_count: u64,
pub availability: f64,
#[ts(type = "number")]
pub connected_peers: u32,
pub buckets: Vec<TorrentAvailabilityBucket>,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct TorrentMoveProgressEvent {
pub id: String,
pub fraction: f64,
#[ts(type = "number")]
pub copied_bytes: u64,
#[ts(type = "number")]
pub total_bytes: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
+1644 -6
View File
File diff suppressed because it is too large Load Diff
+615 -4
View File
@@ -10,7 +10,7 @@ use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Manager};
use tokio::sync::{Mutex, Notify, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore};
use ts_rs::TS;
@@ -37,6 +37,10 @@ pub const MAX_TORRENT_NETWORK_VALUE_LENGTH: usize = 256;
pub const MAX_TORRENT_PEER_ID_PREFIX_BYTES: usize = 20;
pub const MAX_TORRENT_PEER_AGENT_LENGTH: usize = 128;
pub const MAX_TORRENT_PIECES_FOR_PROGRESS: u64 = 10_000_000;
pub const MAX_TORRENT_AVAILABILITY_PEERS: usize = 4_096;
/// Poller gaps beyond this bounded interval are treated conservatively. In
/// particular, a suspended machine must not accrue wall-clock seed time.
pub const MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS: u64 = 5;
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;
@@ -479,6 +483,97 @@ pub struct Aria2GidMapping {
pub epoch: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TorrentTelemetrySnapshot {
pub uploaded_bytes: u64,
pub seeded_seconds: u64,
}
#[derive(Debug, Clone)]
struct TorrentTelemetryState {
gid: String,
epoch: u64,
last_upload_length: Option<u64>,
uploaded_bytes: u64,
seeded_seconds: u64,
last_observed_at: Option<Instant>,
last_was_seeding: bool,
}
impl TorrentTelemetryState {
fn new(gid: &str, epoch: u64, now: Instant) -> Self {
Self {
gid: gid.to_string(),
epoch,
last_upload_length: None,
uploaded_bytes: 0,
seeded_seconds: 0,
last_observed_at: Some(now),
last_was_seeding: false,
}
}
fn snapshot(&self) -> TorrentTelemetrySnapshot {
TorrentTelemetrySnapshot {
uploaded_bytes: self.uploaded_bytes,
seeded_seconds: self.seeded_seconds,
}
}
fn observe(
&mut self,
gid: &str,
epoch: u64,
upload_length: Option<u64>,
is_seeding: bool,
now: Instant,
) -> TorrentTelemetrySnapshot {
if self.gid != gid || self.epoch != epoch {
// A new GID or control epoch is a new daemon counter lifecycle.
// Preserve Firelink totals, but never interpret the new counter
// as a continuation of the old one.
if self.last_was_seeding {
if let Some(previous) = self.last_observed_at {
let seconds = now
.saturating_duration_since(previous)
.min(Duration::from_secs(MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS))
.as_secs();
self.seeded_seconds = self.seeded_seconds.saturating_add(seconds);
}
}
self.gid = gid.to_string();
self.epoch = epoch;
self.last_upload_length = None;
self.last_observed_at = Some(now);
self.last_was_seeding = false;
} else if self.last_was_seeding {
if let Some(previous) = self.last_observed_at {
let seconds = now
.saturating_duration_since(previous)
.min(Duration::from_secs(MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS))
.as_secs();
self.seeded_seconds = self.seeded_seconds.saturating_add(seconds);
}
}
if let Some(current) = upload_length {
if let Some(previous) = self.last_upload_length {
if current >= previous {
self.uploaded_bytes = self
.uploaded_bytes
.saturating_add(current.saturating_sub(previous));
}
// A decreased Aria2 counter is a daemon/lifecycle reset. The
// current value becomes the new baseline, without adding it.
}
self.last_upload_length = Some(current);
}
self.last_observed_at = Some(now);
self.last_was_seeding = is_seeding;
self.snapshot()
}
}
/// Owns one per-download control lock and removes its idle map entry when the
/// last operation for that download finishes.
pub struct Aria2ControlGuard {
@@ -786,6 +881,11 @@ pub struct QueueManager<R: tauri::Runtime = tauri::Wry> {
/// alive, but release the download semaphore while they own a seed slot.
seed_capacity: StdMutex<SeedCapacityState>,
seed_budgets: StdMutex<HashMap<String, SeedBudget>>,
/// Firelink lifetime Torrent upload/seed accounting. Raw Aria2 counters
/// are scoped to the current GID and control epoch and never leave this
/// process as durable state.
torrent_telemetry: Mutex<HashMap<String, TorrentTelemetryState>>,
torrent_move_cancellations: Mutex<HashSet<String>>,
/// aria2 gid -> download id map (shared with the WS poller).
pub aria2_gids: Arc<std::sync::RwLock<HashMap<String, Aria2GidMapping>>>,
@@ -882,6 +982,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
..SeedCapacityState::default()
}),
seed_budgets: StdMutex::new(HashMap::new()),
torrent_telemetry: Mutex::new(HashMap::new()),
torrent_move_cancellations: Mutex::new(HashSet::new()),
aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())),
pending_completion: Arc::new(Mutex::new(HashMap::new())),
aria2_payloads: Mutex::new(HashMap::new()),
@@ -907,6 +1009,86 @@ impl<R: tauri::Runtime> QueueManager<R> {
Arc::clone(&self.power_manager)
}
/// Accept one lifecycle-fenced Aria2 status sample and return Firelink's
/// monotonic lifetime counters. Poller callers must already have checked
/// the mapping; the key and epoch checks here provide a second fence at
/// the accounting owner itself.
pub async fn observe_torrent_telemetry(
&self,
id: &str,
gid: &str,
epoch: u64,
upload_length: Option<u64>,
is_seeding: bool,
now: Instant,
) -> TorrentTelemetrySnapshot {
let mut telemetry = self.torrent_telemetry.lock().await;
let state = telemetry
.entry(id.to_string())
.or_insert_with(|| TorrentTelemetryState::new(gid, epoch, now));
state.observe(gid, epoch, upload_length, is_seeding, now)
}
/// Restore the durable Firelink totals before the first raw Aria2 sample
/// for a download. Raw upload counters are intentionally not restored;
/// the next observation establishes a fresh GID/epoch baseline.
pub async fn hydrate_torrent_telemetry(
&self,
id: &str,
uploaded_bytes: u64,
seeded_seconds: u64,
) {
let mut telemetry = self.torrent_telemetry.lock().await;
let state = telemetry
.entry(id.to_string())
.or_insert_with(|| TorrentTelemetryState::new("", 0, Instant::now()));
state.uploaded_bytes = state.uploaded_bytes.max(uploaded_bytes);
state.seeded_seconds = state.seeded_seconds.max(seeded_seconds);
}
/// Clear the one-shot integrity override only for the still-current
/// lifecycle. A normal user integrity preference is never changed here.
pub async fn clear_torrent_relocation_check(&self, id: &str, epoch: u64) -> bool {
if !self.is_aria2_control_epoch_current(id, epoch).await {
return false;
}
let mut payloads = self.aria2_payloads.lock().await;
let Some(payload) = payloads.get_mut(id) else {
return false;
};
if !payload.is_torrent {
return false;
}
payload.torrent_check_integrity = false;
true
}
pub async fn begin_torrent_move(&self, id: &str) {
self.torrent_move_cancellations.lock().await.remove(id);
}
pub async fn cancel_torrent_move(&self, id: &str) {
self.torrent_move_cancellations
.lock()
.await
.insert(id.to_string());
}
pub async fn torrent_move_cancelled(&self, id: &str) -> bool {
self.torrent_move_cancellations.lock().await.contains(id)
}
pub async fn finish_torrent_move(&self, id: &str) {
self.torrent_move_cancellations.lock().await.remove(id);
}
/// Drop counters after terminal cleanup/removal. Persisted lifetime
/// totals remain owned by the DownloadItem row; this only removes raw
/// process-local lifecycle state.
pub async fn forget_torrent_telemetry(&self, id: &str) {
self.torrent_telemetry.lock().await.remove(id);
}
pub fn app_handle(&self) -> AppHandle<R> {
self.app_handle.clone()
}
@@ -1603,6 +1785,17 @@ impl<R: tauri::Runtime> QueueManager<R> {
.is_some_and(torrent_seeding_requested)
}
/// Whether a currently seeding Torrent owns the Firelink permit that
/// allows seed-time accounting. Separate seed slots require explicit
/// ownership; legacy single-pool mode keeps the transfer permit live.
pub async fn aria2_torrent_seed_permit_owned(&self, id: &str) -> bool {
if self.seed_capacity_enabled() {
self.seed_owner(id)
} else {
self.aria2_torrent_seeding_requested(id).await
}
}
pub async fn aria2_is_torrent(&self, id: &str) -> bool {
self.aria2_payloads
.lock()
@@ -1740,7 +1933,16 @@ impl<R: tauri::Runtime> QueueManager<R> {
&self,
id: &str,
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
let _control_guard = self.acquire_aria2_control(id).await;
let control_guard = self.acquire_aria2_control(id).await;
self.get_aria2_torrent_web_seeds_locked(id, &control_guard)
.await
}
pub async fn get_aria2_torrent_web_seeds_locked(
&self,
id: &str,
_control_guard: &Aria2ControlGuard,
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
let payload = self
.aria2_payloads
.lock()
@@ -1776,7 +1978,17 @@ impl<R: tauri::Runtime> QueueManager<R> {
id: &str,
seeds: &[crate::ipc::TorrentWebSeed],
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
let _control_guard = self.acquire_aria2_control(id).await;
let control_guard = self.acquire_aria2_control(id).await;
self.normalize_aria2_torrent_web_seeds_locked(id, seeds, &control_guard)
.await
}
pub async fn normalize_aria2_torrent_web_seeds_locked(
&self,
id: &str,
seeds: &[crate::ipc::TorrentWebSeed],
_control_guard: &Aria2ControlGuard,
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
let payload = self
.aria2_payloads
.lock()
@@ -1847,7 +2059,23 @@ impl<R: tauri::Runtime> QueueManager<R> {
),
String,
> {
let _control_guard = self.acquire_aria2_control(id).await;
let control_guard = self.acquire_aria2_control(id).await;
self.set_aria2_torrent_web_seeds_locked(id, seeds, &control_guard)
.await
}
pub async fn set_aria2_torrent_web_seeds_locked(
&self,
id: &str,
seeds: Vec<crate::ipc::TorrentWebSeed>,
_control_guard: &Aria2ControlGuard,
) -> Result<
(
Vec<crate::ipc::TorrentWebSeed>,
Vec<crate::ipc::TorrentWebSeed>,
),
String,
> {
let old_payload = self
.aria2_payloads
.lock()
@@ -2349,6 +2577,87 @@ impl<R: tauri::Runtime> QueueManager<R> {
Ok(diagnostics)
}
/// Compute bounded, anonymized swarm availability for the current
/// Torrent lifecycle. The raw local/peer bitfields are consumed in native
/// memory and never returned to the frontend.
pub async fn get_aria2_torrent_availability(
&self,
id: &str,
) -> Result<crate::ipc::TorrentAvailabilitySnapshot, String> {
let _control_guard = self.acquire_aria2_control(id).await;
if !self.is_registered(id).await
|| !matches!(self.active_kind(id).await, Some(TaskKind::Aria2))
{
return Err("Torrent availability is unavailable for this lifecycle".to_string());
}
if !self
.aria2_payloads
.lock()
.await
.get(id)
.is_some_and(|payload| payload.is_torrent)
{
return Err("download is not a Torrent transfer".to_string());
}
let gid = self
.aria2_gid_for_download(id)
.ok_or_else(|| "active Torrent has no gid".to_string())?;
let expected_mapping = self
.aria2_gid_mapping(&gid)
.ok_or_else(|| "active Torrent has no current gid mapping".to_string())?;
if expected_mapping.id != id
|| !self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
{
return Err("active Torrent has a stale control epoch".to_string());
}
let state = self.app_handle.state::<crate::AppState>();
let status = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.tellStatus",
serde_json::json!([gid, ["bitfield", "numPieces"]]),
)
.await
.map_err(|error| {
format!(
"aria2.tellStatus failed: {}",
crate::redact_sensitive_text(&error)
)
})?;
if !self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
|| !self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
{
return Err("Torrent lifecycle changed while reading availability".to_string());
}
let peers = crate::rpc_call(
state.aria2_port.load(std::sync::atomic::Ordering::Relaxed),
&state.aria2_secret,
"aria2.getPeers",
serde_json::json!([gid]),
)
.await
.map_err(|error| {
format!(
"aria2.getPeers failed: {}",
crate::redact_sensitive_text(&error)
)
})?;
let snapshot = parse_torrent_availability(status, peers)?;
if !self.is_registered(id).await
|| !self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
|| !self
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
.await
{
return Err("Torrent lifecycle changed while reading availability".to_string());
}
Ok(snapshot)
}
/// Return a lifecycle-fenced, metadata-derived projection of Aria2's
/// per-file progress. The daemon's absolute paths and URI lists are never
/// copied across the boundary.
@@ -3491,6 +3800,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
.is_some_and(|payload| payload.is_torrent && payload.torrent_remove_unselected_file);
match outcome {
PendingOutcome::Complete => {
self.forget_torrent_telemetry(id).await;
self.clear_aria2_retry_state(id).await;
self.forget_aria2_gid(id).await;
if torrent_removal_requested {
@@ -3535,6 +3845,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
self.emit_state(id, restored_status);
}
PendingOutcome::Error(error) => {
self.forget_torrent_telemetry(id).await;
if !verification_only && error.to_ascii_lowercase().contains("checksum") {
log::warn!("Checksum error detected for {}, cleaning up assets", id);
if let Ok(paths) =
@@ -4967,6 +5278,140 @@ fn aria2_peer_bool(value: Option<&serde_json::Value>) -> bool {
}
}
fn parse_torrent_availability_decimal(
object: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<u64, String> {
let value = object
.get(field)
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("aria2.tellStatus returned an invalid {field}"))?;
if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(format!("aria2.tellStatus returned an invalid {field}"));
}
value
.parse::<u64>()
.map_err(|_| format!("aria2.tellStatus returned an invalid {field}"))
}
fn decode_torrent_availability_bitfield(
value: &str,
piece_count: u64,
) -> Result<Vec<u8>, String> {
if piece_count == 0 || piece_count > MAX_TORRENT_PIECES_FOR_PROGRESS {
return Err("Torrent availability has an unsupported piece count".to_string());
}
let byte_count = piece_count
.checked_add(7)
.and_then(|value| value.checked_div(8))
.ok_or_else(|| "Torrent availability bitfield is oversized".to_string())?;
let expected_hex_length = byte_count
.checked_mul(2)
.ok_or_else(|| "Torrent availability bitfield is oversized".to_string())?;
if value.len() != usize::try_from(expected_hex_length).unwrap_or(usize::MAX)
|| !value.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return Err("Torrent availability bitfield is malformed".to_string());
}
let mut bytes = Vec::with_capacity(byte_count as usize);
for pair in value.as_bytes().chunks_exact(2) {
let high = char::from(pair[0])
.to_digit(16)
.ok_or_else(|| "Torrent availability bitfield is malformed".to_string())?;
let low = char::from(pair[1])
.to_digit(16)
.ok_or_else(|| "Torrent availability bitfield is malformed".to_string())?;
bytes.push(((high << 4) | low) as u8);
}
if piece_count % 8 != 0 {
let overflow_mask = (1u8 << (8 - piece_count as u8 % 8)) - 1;
if bytes.last().is_some_and(|byte| byte & overflow_mask != 0) {
return Err("Torrent availability bitfield has overflow bits".to_string());
}
}
Ok(bytes)
}
fn torrent_availability_piece_is_set(bitfield: &[u8], index: usize) -> bool {
bitfield[index / 8] & (1 << (7 - index % 8)) != 0
}
pub(crate) fn parse_torrent_availability(
status: serde_json::Value,
peers: serde_json::Value,
) -> Result<crate::ipc::TorrentAvailabilitySnapshot, String> {
let status = status
.as_object()
.ok_or_else(|| "aria2.tellStatus returned malformed Torrent availability".to_string())?;
let piece_count = parse_torrent_availability_decimal(status, "numPieces")?;
let local_bitfield = status
.get("bitfield")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| "aria2.tellStatus has no Torrent availability bitfield yet".to_string())?;
let local_bitfield = decode_torrent_availability_bitfield(local_bitfield, piece_count)?;
let peers = peers
.as_array()
.ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?;
if peers.len() > MAX_TORRENT_AVAILABILITY_PEERS {
return Err("aria2.getPeers returned too many peers for availability".to_string());
}
let mut copies = vec![0u16; piece_count as usize];
for index in 0..piece_count as usize {
if torrent_availability_piece_is_set(&local_bitfield, index) {
copies[index] = 1;
}
}
for peer in peers {
let Some(peer) = peer.as_object() else {
// Peer projections are network-derived and may be incomplete
// while Aria2 refreshes its peer table. Preserve the connected
// count but omit an unusable contribution rather than failing
// availability for the whole swarm.
continue;
};
let Some(bitfield_value) = peer.get("bitfield") else {
// Aria2 can report a connected peer before the handshake has
// supplied its piece bitfield. Keep that peer in the connected
// count, but do not let it make the whole availability snapshot
// unavailable. A present, non-string bitfield remains malformed.
continue;
};
let Some(bitfield) = bitfield_value.as_str() else {
continue;
};
let Ok(bitfield) = decode_torrent_availability_bitfield(bitfield, piece_count) else {
continue;
};
for index in 0..piece_count as usize {
if torrent_availability_piece_is_set(&bitfield, index) {
copies[index] = copies[index].saturating_add(1);
}
}
}
let minimum = copies.iter().copied().min().unwrap_or(0);
let above_minimum = copies.iter().filter(|count| **count > minimum).count();
let availability = minimum as f64 + above_minimum as f64 / piece_count as f64;
let bucket_count = piece_count.min(256) as usize;
let mut buckets = Vec::with_capacity(bucket_count);
for bucket_index in 0..bucket_count {
let start = piece_count * bucket_index as u64 / bucket_count as u64;
let end = piece_count * (bucket_index as u64 + 1) / bucket_count as u64;
let minimum_copies = copies[start as usize..end as usize]
.iter()
.copied()
.min()
.unwrap_or(0);
buckets.push(crate::ipc::TorrentAvailabilityBucket { minimum_copies });
}
Ok(crate::ipc::TorrentAvailabilitySnapshot {
piece_count,
availability,
connected_peers: peers.len().try_into().unwrap_or(u32::MAX),
buckets,
})
}
pub(crate) fn parse_torrent_peer_diagnostics(
result: serde_json::Value,
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
@@ -7907,4 +8352,170 @@ mod tests {
assert_eq!(automatic_retry_limit(Some(0)), 0);
assert_eq!(automatic_retry_limit(Some(2)), 2);
}
#[test]
fn torrent_availability_aggregates_local_and_peer_copies_without_exposing_bitfields() {
let snapshot = parse_torrent_availability(
serde_json::json!({ "numPieces": "4", "bitfield": "f0" }),
serde_json::json!([
{ "bitfield": "30", "ip": "192.0.2.1" }
]),
)
.expect("availability should parse");
assert_eq!(snapshot.piece_count, 4);
assert_eq!(snapshot.connected_peers, 1);
assert!((snapshot.availability - 1.5).abs() < f64::EPSILON);
assert_eq!(snapshot.buckets.len(), 4);
assert_eq!(snapshot.buckets[0].minimum_copies, 1);
}
#[test]
fn torrent_availability_rejects_malformed_and_overflow_bitfields() {
assert!(parse_torrent_availability(
serde_json::json!({ "numPieces": "4", "bitfield": "f1" }),
serde_json::json!([]),
)
.is_err());
let snapshot = parse_torrent_availability(
serde_json::json!({ "numPieces": "4", "bitfield": "f0" }),
serde_json::json!([{ "bitfield": "0" }]),
)
.expect("malformed peer data should be omitted");
assert_eq!(snapshot.connected_peers, 1);
assert_eq!(snapshot.availability, 1.0);
}
#[test]
fn torrent_availability_ignores_peers_before_their_bitfield_handshake() {
let snapshot = parse_torrent_availability(
serde_json::json!({ "numPieces": "4", "bitfield": "f0" }),
serde_json::json!([
{ "ip": "192.0.2.1" },
{ "bitfield": "30" }
]),
)
.expect("a peer without a handshake bitfield is not malformed");
assert_eq!(snapshot.connected_peers, 2);
assert!((snapshot.availability - 1.5).abs() < f64::EPSILON);
}
#[test]
fn torrent_telemetry_counts_only_monotonic_upload_deltas() {
let start = Instant::now();
let mut state = TorrentTelemetryState::new("gid-1", 7, start);
assert_eq!(
state.observe("gid-1", 7, Some(100), false, start),
TorrentTelemetrySnapshot {
uploaded_bytes: 0,
seeded_seconds: 0
}
);
assert_eq!(
state.observe(
"gid-1",
7,
Some(180),
false,
start + Duration::from_secs(1)
)
.uploaded_bytes,
80
);
// A daemon counter reset establishes a new baseline and contributes
// no bytes from the reset itself.
assert_eq!(
state.observe(
"gid-1",
7,
Some(12),
false,
start + Duration::from_secs(2)
)
.uploaded_bytes,
80
);
assert_eq!(
state.observe(
"gid-1",
7,
Some(20),
false,
start + Duration::from_secs(3)
)
.uploaded_bytes,
88
);
}
#[test]
fn torrent_telemetry_restarts_baseline_on_gid_or_epoch_replacement() {
let start = Instant::now();
let mut state = TorrentTelemetryState::new("gid-1", 1, start);
state.observe("gid-1", 1, Some(500), false, start);
state.observe("gid-1", 1, Some(525), false, start + Duration::from_secs(1));
let snapshot = state.observe("gid-2", 2, Some(4), false, start + Duration::from_secs(2));
assert_eq!(snapshot.uploaded_bytes, 25);
assert_eq!(
state.observe("gid-2", 2, Some(9), false, start + Duration::from_secs(3))
.uploaded_bytes,
30
);
}
#[test]
fn torrent_telemetry_closes_the_previous_seed_interval_on_lifecycle_replacement() {
let start = Instant::now();
let mut state = TorrentTelemetryState::new("gid-1", 1, start);
state.observe("gid-1", 1, Some(0), true, start);
let snapshot = state.observe("gid-2", 2, Some(4), false, start + Duration::from_secs(3));
assert_eq!(snapshot.seeded_seconds, 3);
assert_eq!(snapshot.uploaded_bytes, 0);
}
#[test]
fn torrent_telemetry_counts_seed_seconds_only_for_the_previous_seed_interval() {
let start = Instant::now();
let mut state = TorrentTelemetryState::new("gid-1", 1, start);
state.observe("gid-1", 1, Some(0), true, start);
assert_eq!(
state.observe(
"gid-1",
1,
Some(10),
true,
start + Duration::from_secs(4)
)
.seeded_seconds,
4
);
assert_eq!(
state.observe(
"gid-1",
1,
Some(10),
false,
start + Duration::from_secs(9)
)
.seeded_seconds,
9
);
}
#[test]
fn torrent_telemetry_caps_long_observer_gaps() {
let start = Instant::now();
let mut state = TorrentTelemetryState::new("gid-1", 1, start);
state.observe("gid-1", 1, Some(0), true, start);
let snapshot = state.observe(
"gid-1",
1,
Some(1),
true,
start + Duration::from_secs(MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS + 3600),
);
assert_eq!(
snapshot.seeded_seconds,
MAX_TORRENT_SEED_ACCOUNTING_INTERVAL_SECS
);
}
}
+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, torrentFileAllocation?: string, torrentVerifyOnly?: boolean, torrentVerifyRestoreStatus?: 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, torrentUploadedBytes?: number, torrentSeededSeconds?: number, torrentRelocationCheckPending?: boolean, torrentMoveDestination?: string, torrentMoveRestoreStatus?: DownloadStatus, torrentWebSeeds?: Array<TorrentWebSeed>, torrentWebSeedsNative?: 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 DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, uploaded_bytes?: number, upload_speed?: string, num_seeders?: number, };
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, uploaded_bytes?: number, upload_speed?: string, num_seeders?: number, torrent_seeded_seconds?: number, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying" | "verifying";
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "seeding" | "waitingToSeed" | "paused" | "completed" | "failed" | "queued" | "retrying" | "verifying" | "moving";
@@ -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 TorrentAvailabilityBucket = { minimumCopies: 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 { TorrentAvailabilityBucket } from "./TorrentAvailabilityBucket";
export type TorrentAvailabilitySnapshot = { pieceCount: number, availability: number, connectedPeers: number, buckets: Array<TorrentAvailabilityBucket>, };
+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 TorrentMoveProgressEvent = { id: string, fraction: number, copiedBytes: number, totalBytes: number, };
+108 -21
View File
@@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat
import { open } from '@tauri-apps/plugin-dialog';
import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import {
expandTilde,
@@ -52,6 +52,7 @@ import {
type MediaSelection
} from '../utils/addDownloadMetadata';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
import { TorrentWebSeedEditor } from './TorrentWebSeedEditor';
const formatBytes = (bytes: number) => {
const k = 1024;
@@ -241,7 +242,17 @@ export const AddDownloadsModal = () => {
const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState('');
const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0');
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState('');
const [torrentFileAllocation, setTorrentFileAllocation] = useState<TorrentFileAllocation>('prealloc');
const [torrentPreviewHeadEnabled, setTorrentPreviewHeadEnabled] = useState(false);
const [torrentPreviewHeadSize, setTorrentPreviewHeadSize] = useState('1M');
const [torrentPreviewTailEnabled, setTorrentPreviewTailEnabled] = useState(false);
const [torrentPreviewTailSize, setTorrentPreviewTailSize] = useState('1M');
const torrentPreviewPriority = serializeTorrentPreviewPriority(
torrentPreviewHeadEnabled,
torrentPreviewHeadSize,
torrentPreviewTailEnabled,
torrentPreviewTailSize
);
const [freeSpace, setFreeSpace] = useState('Unknown');
const freeSpaceRequestRef = useRef(0);
@@ -389,6 +400,11 @@ export const AddDownloadsModal = () => {
setTorrentTrackerTimeout('');
setTorrentTrackerInterval('0');
setTorrentStopTimeout('0');
setTorrentFileAllocation('prealloc');
setTorrentPreviewHeadEnabled(false);
setTorrentPreviewHeadSize('1M');
setTorrentPreviewTailEnabled(false);
setTorrentPreviewTailSize('1M');
setUseAuth(false);
setUsername('');
setPassword('');
@@ -1005,10 +1021,18 @@ export const AddDownloadsModal = () => {
addToast({ message: t($ => $.addDownloads.torrentTrackerIntervalInvalid), variant: 'error', isActionable: true });
return;
}
if (hasSelectedTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) {
if (hasSelectedTorrent && (torrentPreviewHeadEnabled || torrentPreviewTailEnabled) && !torrentPreviewPriority) {
addToast({ message: t($ => $.addDownloads.torrentPrioritizePieceInvalid), variant: 'error', isActionable: true });
return;
}
for (const item of selectedItems) {
if (!item.isTorrent || !item.torrentFiles?.length) continue;
const rows = item.torrentWebSeedRows ?? [];
if (!normalizeTorrentWebSeedDrafts(rows, item.torrentFiles)) {
addToast({ message: t($ => $.properties.torrentWebSeedsFailed), variant: 'error', isActionable: true });
return;
}
}
if (
hasSelectedTorrent
&& torrentStopTimeout.trim()
@@ -1529,7 +1553,11 @@ export const AddDownloadsModal = () => {
? Number(torrentTrackerInterval)
: undefined,
torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined,
torrentPrioritizePiece: item.isTorrent ? normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined : undefined,
torrentPrioritizePiece: item.isTorrent ? torrentPreviewPriority || undefined : undefined,
torrentFileAllocation: item.isTorrent ? torrentFileAllocation : undefined,
torrentWebSeeds: item.isTorrent && item.torrentFiles
? normalizeTorrentWebSeedDrafts(item.torrentWebSeedRows ?? [], item.torrentFiles) || undefined
: undefined,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
sizeBytes: item.sizeBytes
}, action);
@@ -2112,6 +2140,24 @@ export const AddDownloadsModal = () => {
</section>
)}
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
<section className="add-download-section relative overflow-hidden p-4">
<div className="add-download-section-title flex items-center gap-2 mb-3">
<Link size={16} className="text-blue-500" /> {t($ => $.properties.torrentWebSeeds)}
</div>
<p className="text-[11px] text-text-muted mb-3">{t($ => $.properties.torrentWebSeedsHint)}</p>
<TorrentWebSeedEditor
files={parsedItems[selectedItemIndex].torrentFiles ?? []}
rows={parsedItems[selectedItemIndex].torrentWebSeedRows ?? []}
onChange={rows => setParsedItems(items => items.map((item, index) => index === selectedItemIndex
? { ...item, torrentWebSeedRows: rows }
: item
))}
idPrefix="add-torrent-web-seed"
/>
</section>
)}
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
<section className="add-download-section relative overflow-hidden p-4">
<div className="add-download-section-title flex items-center gap-2 mb-3">
@@ -2195,6 +2241,64 @@ export const AddDownloadsModal = () => {
</span>
</span>
</label>
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-file-allocation" className="text-text-muted">
{t($ => $.properties.torrentFileAllocation)}
</label>
<select
id="torrent-file-allocation"
value={torrentFileAllocation}
onChange={event => setTorrentFileAllocation(event.currentTarget.value as TorrentFileAllocation)}
aria-describedby="torrent-file-allocation-hint"
className="app-control max-w-56 px-2 py-1 text-xs"
>
<option value="prealloc">{t($ => $.properties.torrentFileAllocationPrealloc)}</option>
<option value="none">{t($ => $.properties.torrentFileAllocationNone)}</option>
</select>
<p id="torrent-file-allocation-hint" className="col-span-2 text-[10px] text-text-muted">
{t($ => $.properties.torrentFileAllocationHint)}
</p>
</div>
<div className="space-y-2 pt-2 border-t border-border-modal/50">
<span className="block text-text-muted">{t($ => $.properties.torrentPrioritizePiece)}</span>
<label className="flex items-center gap-2 text-text-primary">
<input
type="checkbox"
checked={torrentPreviewHeadEnabled}
onChange={event => setTorrentPreviewHeadEnabled(event.target.checked)}
className="accent-blue-500"
/>
{t($ => $.properties.torrentPrioritizePieceHead)}
<input
type="text"
value={torrentPreviewHeadSize}
onChange={event => setTorrentPreviewHeadSize(event.currentTarget.value)}
disabled={!torrentPreviewHeadEnabled}
aria-label={t($ => $.properties.torrentPrioritizePieceSize)}
className="app-control w-20 px-2 py-1 text-end font-mono disabled:opacity-50"
/>
</label>
<label className="flex items-center gap-2 text-text-primary">
<input
type="checkbox"
checked={torrentPreviewTailEnabled}
onChange={event => setTorrentPreviewTailEnabled(event.target.checked)}
className="accent-blue-500"
/>
{t($ => $.properties.torrentPrioritizePieceTail)}
<input
type="text"
value={torrentPreviewTailSize}
onChange={event => setTorrentPreviewTailSize(event.currentTarget.value)}
disabled={!torrentPreviewTailEnabled}
aria-label={t($ => $.properties.torrentPrioritizePieceSize)}
className="app-control w-20 px-2 py-1 text-end font-mono disabled:opacity-50"
/>
</label>
<p id="torrent-prioritize-piece-hint" className="text-[10px] text-text-muted">
{t($ => $.properties.torrentPrioritizePieceHint)}
</p>
</div>
<div className="grid grid-cols-[1fr_auto] gap-2 items-center pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-encryption-policy" className="text-text-muted">
{t($ => $.addDownloads.torrentEncryptionPolicy)}
@@ -2382,23 +2486,6 @@ export const AddDownloadsModal = () => {
{t($ => $.addDownloads.torrentStopTimeoutHint)}
</p>
</div>
<div className="pt-2 border-t border-border-modal/50">
<label htmlFor="torrent-prioritize-piece" className="block text-text-muted">
{t($ => $.addDownloads.torrentPrioritizePiece)}
</label>
<input
id="torrent-prioritize-piece"
type="text"
value={torrentPrioritizePiece}
onChange={event => setTorrentPrioritizePiece(event.currentTarget.value)}
placeholder="head=1M,tail=1M"
aria-describedby="torrent-prioritize-piece-hint"
className="app-control mt-1 w-full px-2.5 py-1.5 text-xs font-mono"
/>
<p id="torrent-prioritize-piece-hint" className="mt-1 text-[10px] text-text-muted">
{t($ => $.addDownloads.torrentPrioritizePieceHint)}
</p>
</div>
</div>
</section>
)}
+11 -3
View File
@@ -13,6 +13,7 @@ import { useSettingsStore } from '../store/useSettingsStore';
import { formatDateTime } from '../utils/dateTime';
import {
downloadProgressColorClass,
formatTorrentDuration,
formatDownloadTotal,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
@@ -72,6 +73,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
const { t, i18n } = useTranslation();
const calendarPreference = useSettingsStore(state => state.calendarPreference);
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
const moveProgress = useDownloadProgressStore(state => state.moveProgressMap[download.id]);
const rowRef = React.useRef<HTMLDivElement>(null);
const [isRowHovered, setIsRowHovered] = React.useState(false);
const [isRowKeyboardFocused, setIsRowKeyboardFocused] = React.useState(false);
@@ -178,7 +180,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
};
}, [isActionVisible, updateActionPosition]);
const displayFraction = download.status === 'downloading' || download.status === 'verifying' || download.status === 'seeding'
const displayFraction = download.status === 'moving'
? moveProgress ?? download.fraction ?? 0
: download.status === 'downloading' || download.status === 'verifying' || download.status === 'seeding'
? liveProgress?.fraction ?? download.fraction ?? 0
: download.fraction ?? 0;
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
@@ -190,7 +194,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
? t($ => $.downloads.values.processing)
: '-';
const displayEta = download.status === 'seeding'
? '-'
? typeof download.torrentSeedRemaining === 'number' && Number.isFinite(download.torrentSeedRemaining) && download.torrentSeedRemaining > 0
? formatTorrentDuration(download.torrentSeedRemaining * 60, i18n.language)
: '-'
: download.status === 'downloading' || download.status === 'verifying'
? liveProgress?.eta ?? download.eta
: download.status === 'processing'
@@ -298,6 +304,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.status === 'seeding' ? 'seeding' :
download.status === 'processing' ? 'processing' :
download.status === 'verifying' ? 'processing' :
download.status === 'moving' ? 'processing' :
download.status === 'queued' || download.status === 'staged' ? 'queued' :
download.status === 'retrying' ? 'retrying' : ''
}`}
@@ -322,6 +329,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.status === 'failed' ? 'download-status-failed' :
download.status === 'processing' ? 'download-status-processing' :
download.status === 'verifying' ? 'download-status-processing' :
download.status === 'moving' ? 'download-status-processing' :
download.status === 'downloading' ? 'download-status-downloading' :
download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' :
download.status === 'retrying' ? 'download-status-retrying' : ''
@@ -334,7 +342,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
{downloadStatusLabel} #{queueIndex + 1}
</span>
</>
) : download.status === 'downloading' || download.status === 'verifying' ? (
) : download.status === 'downloading' || download.status === 'verifying' || download.status === 'moving' ? (
displayPercent
) : download.status === 'seeding' ? (
displayPercent
+18
View File
@@ -25,6 +25,7 @@ import {
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
import { summarizeDownloads, type DownloadSummary } from '../utils/downloadSummary';
import { readClipboardDownloadUrls } from '../utils/clipboard';
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
import { useTranslation } from 'react-i18next';
import {
sortDownloads,
@@ -2552,6 +2553,23 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
{t($ => $.downloadTable.copyAddress)}
</button>
{contextItem.isTorrent && (
<button
onClick={async () => {
setContextMenu(null);
try {
const magnet = await invoke('get_torrent_magnet_link', { id: contextItem.id });
await writeClipboardText(magnet);
} catch (error) {
showInteractionError(t($ => $.downloadTable.copyMagnetFailed), error);
}
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
{t($ => $.downloadTable.copyMagnet)}
</button>
)}
{contextItem.status === 'completed' && (
<button
onClick={async () => {
+303 -48
View File
@@ -8,10 +8,11 @@ import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgres
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 type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilitySnapshot';
import { invokeCommand as invoke } from '../ipc';
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { open, save } from '@tauri-apps/plugin-dialog';
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
import { resolveCategoryDestination } from '../utils/downloadLocations';
import {
getPauseResumeAction,
@@ -22,12 +23,15 @@ import {
downloadProgressColorClass,
formatDownloadBytes,
formatDownloadTotal,
formatTorrentDuration,
formatTorrentRatio,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
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 { isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, parseTorrentPreviewPriority, serializeTorrentPreviewPriority, torrentWebSeedDraftsFromSeeds, resolveDownloadConnections, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation, type TorrentWebSeedDraft } from '../utils/downloads';
import { useTranslation } from 'react-i18next';
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
import { TorrentWebSeedEditor } from './TorrentWebSeedEditor';
type LoginMode = 'matching' | 'custom' | 'none';
@@ -50,6 +54,9 @@ const isPeerDiagnosticsStatus = (status: string): boolean =>
const isTorrentFileProgressStatus = (status: string): boolean =>
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
const isTorrentAvailabilityStatus = (status: string): boolean =>
['downloading', 'verifying', 'seeding', 'retrying', 'paused'].includes(status);
const formatPeerSpeed = (bytesPerSecond: number): string =>
`${formatDownloadBytes(bytesPerSecond)}/s`;
@@ -78,6 +85,9 @@ export const PropertiesModal = () => {
? state.progressMap[selectedPropertiesDownloadId]
: undefined
));
const moveProgress = useDownloadProgressStore(state =>
selectedPropertiesDownloadId ? state.moveProgressMap[selectedPropertiesDownloadId] : undefined
);
const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore();
@@ -104,7 +114,10 @@ export const PropertiesModal = () => {
const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState('');
const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0');
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState('');
const [torrentPreviewHeadEnabled, setTorrentPreviewHeadEnabled] = useState(false);
const [torrentPreviewHeadSize, setTorrentPreviewHeadSize] = useState('1M');
const [torrentPreviewTailEnabled, setTorrentPreviewTailEnabled] = useState(false);
const [torrentPreviewTailSize, setTorrentPreviewTailSize] = useState('1M');
const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState<TorrentPeerDiagnostics | null>(null);
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
@@ -119,10 +132,15 @@ export const PropertiesModal = () => {
const [torrentPieceProgress, setTorrentPieceProgress] = useState<TorrentPieceProgressSnapshot | null>(null);
const [torrentPieceProgressError, setTorrentPieceProgressError] = useState(false);
const [isTorrentPieceProgressPending, setIsTorrentPieceProgressPending] = useState(false);
const [torrentAvailability, setTorrentAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
const [torrentAvailabilityError, setTorrentAvailabilityError] = useState(false);
const [isTorrentAvailabilityPending, setIsTorrentAvailabilityPending] = useState(false);
const [torrentShareMessage, setTorrentShareMessage] = useState('');
const [isTorrentMovePending, setIsTorrentMovePending] = useState(false);
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
const [torrentWebSeedsText, setTorrentWebSeedsText] = useState('');
const [torrentWebSeedRows, setTorrentWebSeedRows] = useState<TorrentWebSeedDraft[]>([]);
const [torrentWebSeedsError, setTorrentWebSeedsError] = useState(false);
const [isTorrentWebSeedsPending, setIsTorrentWebSeedsPending] = useState(false);
@@ -149,6 +167,7 @@ export const PropertiesModal = () => {
const torrentFileSelectionRequestRef = useRef(0);
const torrentDetailsRequestRef = useRef(0);
const torrentPieceProgressRequestRef = useRef(0);
const torrentAvailabilityRequestRef = useRef(0);
const torrentWebSeedsRequestRef = useRef(0);
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
@@ -175,9 +194,16 @@ export const PropertiesModal = () => {
setTorrentPieceProgress(null);
setTorrentPieceProgressError(false);
setIsTorrentPieceProgressPending(false);
torrentAvailabilityRequestRef.current += 1;
setTorrentAvailability(null);
setTorrentAvailabilityError(false);
setIsTorrentAvailabilityPending(false);
setTorrentShareMessage('');
setIsTorrentMovePending(false);
torrentWebSeedsRequestRef.current += 1;
setTorrentWebSeedsError(false);
setIsTorrentWebSeedsPending(false);
setTorrentWebSeedRows([]);
}, [selectedPropertiesDownloadId]);
useEffect(() => {
@@ -251,10 +277,12 @@ export const PropertiesModal = () => {
setTorrentTrackerTimeout(activeItem.torrentTrackerTimeout === undefined ? '' : String(activeItem.torrentTrackerTimeout));
setTorrentTrackerInterval(activeItem.torrentTrackerInterval === undefined ? '0' : String(activeItem.torrentTrackerInterval));
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
setTorrentPrioritizePiece(activeItem.torrentPrioritizePiece || '');
setTorrentWebSeedsText((activeItem.torrentWebSeeds || [])
.map(seed => `${seed.fileIndex}|${seed.uri}`)
.join('\n'));
const previewPriority = parseTorrentPreviewPriority(activeItem.torrentPrioritizePiece);
setTorrentPreviewHeadEnabled(Boolean(previewPriority.head));
setTorrentPreviewHeadSize(previewPriority.head || '1M');
setTorrentPreviewTailEnabled(Boolean(previewPriority.tail));
setTorrentPreviewTailSize(previewPriority.tail || '1M');
setTorrentWebSeedRows(torrentWebSeedDraftsFromSeeds(activeItem.torrentWebSeeds));
setErrorMessage('');
} else {
setSelectedPropertiesDownloadId(null);
@@ -389,7 +417,7 @@ export const PropertiesModal = () => {
requestId === torrentWebSeedsRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentWebSeedsText(seeds.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n'));
setTorrentWebSeedRows(torrentWebSeedDraftsFromSeeds(seeds));
useDownloadStore.getState().updateDownload(propertiesDownloadId, { torrentWebSeeds: seeds });
}
})
@@ -611,26 +639,126 @@ export const PropertiesModal = () => {
}
};
const handleRefreshTorrentAvailability = async () => {
if (
isTorrentAvailabilityPending
|| !item.isTorrent
|| !isTorrentAvailabilityStatus(item.status)
) return;
const requestId = ++torrentAvailabilityRequestRef.current;
const propertiesDownloadId = item.id;
setIsTorrentAvailabilityPending(true);
setTorrentAvailabilityError(false);
try {
const snapshot = await invoke('get_torrent_availability', { id: propertiesDownloadId });
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
if (
requestId === torrentAvailabilityRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
&& currentItem?.isTorrent
&& isTorrentAvailabilityStatus(currentItem.status)
) {
setTorrentAvailability(snapshot);
}
} catch {
if (
requestId === torrentAvailabilityRequestRef.current
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
) {
setTorrentAvailabilityError(true);
setTorrentAvailability(null);
}
} finally {
if (requestId === torrentAvailabilityRequestRef.current) setIsTorrentAvailabilityPending(false);
}
};
const handleCopyTorrentMagnet = async () => {
if (!item.isTorrent) return;
setTorrentShareMessage('');
try {
const magnet = await invoke('get_torrent_magnet_link', { id: item.id });
await writeClipboardText(magnet);
setTorrentShareMessage(t($ => $.properties.torrentMagnetCopied));
} catch {
setTorrentShareMessage(t($ => $.properties.torrentMagnetCopyFailed));
}
};
const handleExportTorrentMetadata = async () => {
if (!item.isTorrent) return;
setTorrentShareMessage('');
try {
const destination = await save({
defaultPath: `${item.fileName.replace(/\.torrent$/i, '')}.torrent`,
filters: [{ name: 'Torrent metadata', extensions: ['torrent'] }]
});
if (!destination) return;
await invoke('export_torrent_metadata', { id: item.id, destination });
setTorrentShareMessage(t($ => $.properties.torrentMetadataExported));
} catch {
setTorrentShareMessage(t($ => $.properties.torrentMetadataExportFailed));
}
};
const handleMoveTorrentData = async () => {
if (!item.isTorrent || isTorrentMovePending || !['paused', 'completed', 'failed'].includes(item.status)) return;
const propertiesDownloadId = item.id;
const selected = await open({
directory: true,
multiple: false,
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
});
if (!selected || typeof selected !== 'string') return;
if (!window.confirm(t($ => $.properties.torrentMoveConfirm))) return;
setIsTorrentMovePending(true);
setTorrentShareMessage('');
try {
await invoke('move_torrent_data', { id: propertiesDownloadId, destination: selected });
useDownloadStore.getState().updateDownload(propertiesDownloadId, {
destination: selected,
torrentRelocationCheckPending: item.torrentRelocationCheckPending === true
|| item.status === 'paused'
|| item.status === 'failed'
? true
: undefined
});
setSaveLocation(selected);
setTorrentShareMessage(t($ => $.properties.torrentMoveCompleted));
} catch {
setTorrentShareMessage(t($ => $.properties.torrentMoveFailed));
} finally {
setIsTorrentMovePending(false);
}
};
const handleCancelTorrentMove = async () => {
if (!item?.isTorrent || !isTorrentMovePending) return;
try {
await invoke('cancel_torrent_move_data', { id: item.id });
setTorrentShareMessage(t($ => $.properties.torrentMoveCancelRequested));
} catch {
setTorrentShareMessage(t($ => $.properties.torrentMoveFailed));
}
};
const torrentWebSeedFiles = (torrentFileSelection?.files ?? torrentFileProgress?.files ?? [])
.map(file => ({ index: file.index, relativePath: file.relativePath }));
const torrentWebSeedsMetadataPending = torrentWebSeedRows.length > 0 && torrentWebSeedFiles.length === 0;
const handleTorrentWebSeedsSave = async () => {
if (!item?.isTorrent || isTorrentWebSeedsPending) return;
const seeds: TorrentWebSeed[] = [];
for (const line of torrentWebSeedsText.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
const separator = trimmed.indexOf('|');
const fileIndex = Number(separator >= 0 ? trimmed.slice(0, separator).trim() : '');
const uri = separator >= 0 ? trimmed.slice(separator + 1).trim() : '';
if (!Number.isInteger(fileIndex) || fileIndex < 0 || !uri) {
setTorrentWebSeedsError(true);
return;
}
seeds.push({ fileIndex, uri });
const files = torrentWebSeedFiles.map(file => ({ index: file.index }));
const seeds = normalizeTorrentWebSeedDrafts(torrentWebSeedRows, files);
if (!seeds) {
setTorrentWebSeedsError(true);
return;
}
setIsTorrentWebSeedsPending(true);
setTorrentWebSeedsError(false);
try {
const normalized = await invoke('set_torrent_web_seeds', { id: item.id, seeds });
setTorrentWebSeedsText(normalized.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n'));
setTorrentWebSeedRows(torrentWebSeedDraftsFromSeeds(normalized));
useDownloadStore.getState().updateDownload(item.id, { torrentWebSeeds: normalized });
} catch {
setTorrentWebSeedsError(true);
@@ -711,7 +839,13 @@ export const PropertiesModal = () => {
setErrorMessage(t($ => $.properties.torrentTrackerIntervalInvalid));
return;
}
if (item.isTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) {
const torrentPrioritizePiece = serializeTorrentPreviewPriority(
torrentPreviewHeadEnabled,
torrentPreviewHeadSize,
torrentPreviewTailEnabled,
torrentPreviewTailSize
);
if (item.isTorrent && (torrentPreviewHeadEnabled || torrentPreviewTailEnabled) && !torrentPrioritizePiece) {
setErrorMessage(t($ => $.properties.torrentPrioritizePieceInvalid));
return;
}
@@ -786,7 +920,7 @@ export const PropertiesModal = () => {
? Number(torrentTrackerInterval)
: undefined,
torrentStopTimeout: normalizedStopTimeout,
torrentPrioritizePiece: normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined,
torrentPrioritizePiece: torrentPrioritizePiece || undefined,
torrentFileIndices: torrentFileSelection
? (allTorrentFilesSelected ? undefined : selectedTorrentIndices)
: item.torrentFileIndices,
@@ -1018,6 +1152,20 @@ export const PropertiesModal = () => {
const value = item.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
})();
const torrentUploadedBytes = item.isTorrent
? liveProgress?.uploaded_bytes ?? item.torrentUploadedBytes ?? 0
: 0;
const torrentSeededSeconds = item.isTorrent
? liveProgress?.torrent_seeded_seconds ?? item.torrentSeededSeconds ?? 0
: 0;
const torrentRatioDenominator = item.isTorrent
? torrentFileSelection?.files.length
? torrentFileSelection.files.reduce((total, file) => total + (file.selected ? file.length : 0), 0)
: torrentDetails?.totalBytes ?? item.totalBytes ?? 0
: 0;
const torrentRatio = formatTorrentRatio(torrentUploadedBytes, torrentRatioDenominator, i18n.language);
const torrentSeederCount = liveProgress?.num_seeders ?? torrentPeerDiagnostics?.totalSeeders;
const torrentConnectedPeerCount = torrentPeerDiagnostics?.totalPeers;
const statusLabel = t($ => $.downloads.status[item.status]);
const pauseResumeAction = getPauseResumeAction(item.status);
const pauseResumeLabel = pauseResumeAction === 'pause'
@@ -1040,7 +1188,7 @@ export const PropertiesModal = () => {
let StatusIcon = Info;
if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; }
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 === 'processing' || item.status === 'moving') { 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; }
@@ -1113,6 +1261,19 @@ export const PropertiesModal = () => {
</div>
)}
</div>
{item.isTorrent && (
<div className="mt-3 rounded-lg border border-border-modal/70 bg-bg-input/20 p-2.5" aria-label={t($ => $.properties.torrentStatistics)}>
<div className="mb-2 text-[11px] font-semibold text-text-primary">{t($ => $.properties.torrentStatistics)}</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px] sm:grid-cols-3">
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentUploaded)}</span><span className="truncate text-text-secondary">{formatDownloadBytes(torrentUploadedBytes)}</span></div>
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentRatio)}</span><span className="truncate text-text-secondary">{torrentRatio}</span></div>
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentSeededDuration)}</span><span className="truncate text-text-secondary">{formatTorrentDuration(torrentSeededSeconds, i18n.language)}</span></div>
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentConnectedPeers)}</span><span className="truncate text-text-secondary">{torrentConnectedPeerCount ?? '—'}</span></div>
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentSeeders)}</span><span className="truncate text-text-secondary">{torrentSeederCount ?? '—'}</span></div>
<div className="flex min-w-0 gap-1.5"><span className="text-text-muted">{t($ => $.properties.torrentUploadSpeed)}</span><span className="truncate text-text-secondary">{liveProgress?.upload_speed ?? '—'}</span></div>
</div>
</div>
)}
</div>
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
@@ -1347,6 +1508,54 @@ export const PropertiesModal = () => {
</>
)}
</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">{t($ => $.properties.torrentAvailability)}</div>
<button
type="button"
onClick={() => void handleRefreshTorrentAvailability()}
disabled={!isTorrentAvailabilityStatus(item.status) || isTorrentAvailabilityPending}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentAvailabilityPending
? t($ => $.properties.torrentAvailabilityLoading)
: t($ => $.properties.torrentAvailabilityRefresh)}
</button>
</div>
<p className="text-[11px] text-text-muted">{t($ => $.properties.torrentAvailabilityHint)}</p>
{!isTorrentAvailabilityStatus(item.status) && (
<p className="text-[11px] text-text-muted">{t($ => $.properties.torrentAvailabilityUnavailable)}</p>
)}
{torrentAvailabilityError && (
<p className="text-[11px] text-red-400">{t($ => $.properties.torrentAvailabilityFailed)}</p>
)}
{torrentAvailability && (
<>
<div className="text-[11px] text-text-secondary">
{t($ => $.properties.torrentAvailabilitySummary, {
availability: new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 2 }).format(torrentAvailability.availability),
peers: torrentAvailability.connectedPeers,
pieces: torrentAvailability.pieceCount
})}
</div>
<div
className="grid gap-0.5 rounded border border-border-modal/60 bg-bg-input p-1"
style={{ gridTemplateColumns: `repeat(${Math.min(16, Math.max(1, torrentAvailability.buckets.length))}, minmax(0, 1fr))` }}
role="img"
aria-label={t($ => $.properties.torrentAvailabilityMap)}
>
{torrentAvailability.buckets.map((bucket, index) => (
<span
key={index}
className="aspect-square min-w-1 rounded-sm bg-emerald-500 motion-safe:transition-opacity motion-reduce:transition-none"
style={{ opacity: Math.min(1, 0.2 + bucket.minimumCopies / Math.max(1, torrentAvailability.availability + 1)) }}
title={t($ => $.properties.torrentAvailabilityBucket, { copies: bucket.minimumCopies })}
/>
))}
</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">
@@ -1610,21 +1819,45 @@ export const PropertiesModal = () => {
{t($ => $.properties.torrentStopTimeoutHint)}
</p>
</div>
<label className="text-xs text-text-muted text-right" htmlFor="torrent-prioritize-piece-properties">
{t($ => $.properties.torrentPrioritizePiece)}
</label>
<div>
<input
id="torrent-prioritize-piece-properties"
type="text"
value={torrentPrioritizePiece}
onChange={event => setTorrentPrioritizePiece(event.currentTarget.value)}
placeholder="head=1M,tail=1M"
disabled={transferLocked}
aria-describedby="torrent-prioritize-piece-properties-hint"
className="app-control w-full px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
/>
<p id="torrent-prioritize-piece-properties-hint" className="mt-1 text-[11px] text-text-muted">
<div className="col-span-2 space-y-2">
<span className="block text-xs text-text-muted">{t($ => $.properties.torrentPrioritizePiece)}</span>
<label className="flex items-center gap-2 text-xs text-text-primary">
<input
type="checkbox"
checked={torrentPreviewHeadEnabled}
onChange={event => setTorrentPreviewHeadEnabled(event.target.checked)}
disabled={transferLocked}
className="accent-blue-500"
/>
{t($ => $.properties.torrentPrioritizePieceHead)}
<input
type="text"
value={torrentPreviewHeadSize}
onChange={event => setTorrentPreviewHeadSize(event.currentTarget.value)}
disabled={transferLocked || !torrentPreviewHeadEnabled}
aria-label={t($ => $.properties.torrentPrioritizePieceSize)}
className="app-control w-20 px-2 py-1 text-end font-mono disabled:opacity-50"
/>
</label>
<label className="flex items-center gap-2 text-xs text-text-primary">
<input
type="checkbox"
checked={torrentPreviewTailEnabled}
onChange={event => setTorrentPreviewTailEnabled(event.target.checked)}
disabled={transferLocked}
className="accent-blue-500"
/>
{t($ => $.properties.torrentPrioritizePieceTail)}
<input
type="text"
value={torrentPreviewTailSize}
onChange={event => setTorrentPreviewTailSize(event.currentTarget.value)}
disabled={transferLocked || !torrentPreviewTailEnabled}
aria-label={t($ => $.properties.torrentPrioritizePieceSize)}
className="app-control w-20 px-2 py-1 text-end font-mono disabled:opacity-50"
/>
</label>
<p className="text-[11px] text-text-muted">
{t($ => $.properties.torrentPrioritizePieceHint)}
</p>
</div>
@@ -1904,6 +2137,29 @@ export const PropertiesModal = () => {
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
{t($ => $.properties.torrentDetails)}
</h3>
<div className="mb-3 flex flex-wrap items-center gap-2">
<button type="button" onClick={() => void handleCopyTorrentMagnet()} className="app-button px-3 text-xs">
{t($ => $.properties.torrentCopyMagnet)}
</button>
<button type="button" onClick={() => void handleExportTorrentMetadata()} className="app-button px-3 text-xs">
{t($ => $.properties.torrentExportMetadata)}
</button>
{(isTorrentMovePending || ['paused', 'completed', 'failed'].includes(item.status)) && (
<button
type="button"
onClick={() => void (isTorrentMovePending ? handleCancelTorrentMove() : handleMoveTorrentData())}
className="app-button px-3 text-xs disabled:opacity-50"
>
{isTorrentMovePending ? t($ => $.properties.torrentMoveCancel) : t($ => $.properties.torrentMove)}
</button>
)}
{isTorrentMovePending && moveProgress !== undefined && (
<span className="text-[11px] text-text-secondary tabular-nums" role="status">
{Math.round(moveProgress * 100)}%
</span>
)}
{torrentShareMessage && <span className="text-[11px] text-text-secondary" role="status">{torrentShareMessage}</span>}
</div>
{isTorrentDetailsPending && (
<p className="text-xs text-text-muted">{t($ => $.properties.torrentDetailsLoading)}</p>
)}
@@ -1972,13 +2228,12 @@ export const PropertiesModal = () => {
{t($ => $.properties.torrentWebSeeds)}
</h3>
<p className="text-xs text-text-muted mb-2">{t($ => $.properties.torrentWebSeedsHint)}</p>
<textarea
value={torrentWebSeedsText}
onChange={event => setTorrentWebSeedsText(event.target.value)}
placeholder={t($ => $.properties.torrentWebSeedsPlaceholder)}
<TorrentWebSeedEditor
files={torrentWebSeedFiles}
rows={torrentWebSeedRows}
onChange={setTorrentWebSeedRows}
disabled={isTorrentWebSeedsPending}
aria-label={t($ => $.properties.torrentWebSeeds)}
className="w-full h-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"
idPrefix="properties-torrent-web-seed"
/>
<div className="flex items-center justify-between mt-2 gap-2">
<span className="text-xs text-red-500">
@@ -1987,7 +2242,7 @@ export const PropertiesModal = () => {
<button
type="button"
onClick={() => void handleTorrentWebSeedsSave()}
disabled={isTorrentWebSeedsPending}
disabled={isTorrentWebSeedsPending || torrentWebSeedsMetadataPending}
className="app-button px-3 text-xs"
>
{isTorrentWebSeedsPending ? t($ => $.properties.torrentWebSeedsLoading) : t($ => $.properties.torrentWebSeedsApply)}
+126
View File
@@ -0,0 +1,126 @@
import type { TorrentFile } from '../bindings/TorrentFile';
import type { TorrentFileSelectionEntry } from '../bindings/TorrentFileSelectionEntry';
import { normalizeTorrentWebSeedDrafts, type TorrentWebSeedDraft } from '../utils/downloads';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
type TorrentWebSeedFile = Pick<TorrentFile, 'index' | 'path'> | Pick<TorrentFileSelectionEntry, 'index' | 'relativePath'>;
type Props = {
files: readonly TorrentWebSeedFile[];
rows: readonly TorrentWebSeedDraft[];
onChange: (rows: TorrentWebSeedDraft[]) => void;
disabled?: boolean;
idPrefix: string;
};
const filePath = (file: TorrentWebSeedFile): string => 'path' in file ? file.path : file.relativePath;
export const TorrentWebSeedEditor = ({ files, rows, onChange, disabled = false, idPrefix }: Props) => {
const { t } = useTranslation();
const uriRefs = useRef<Array<HTMLInputElement | null>>([]);
const focusAfterRemoveRef = useRef<number | null>(null);
const filesForValidation = files.map(file => ({ index: file.index }));
const rowsAreValid = normalizeTorrentWebSeedDrafts(rows, filesForValidation) !== null;
useEffect(() => {
const rowIndex = focusAfterRemoveRef.current;
focusAfterRemoveRef.current = null;
if (rowIndex !== null) uriRefs.current[rowIndex]?.focus();
}, [rows]);
const addRow = () => onChange([...rows, { fileIndex: files[0]?.index ?? null, uri: '' }]);
const updateRow = (rowIndex: number, update: Partial<TorrentWebSeedDraft>) => onChange(
rows.map((row, index) => index === rowIndex ? { ...row, ...update } : row)
);
const removeRow = (rowIndex: number) => {
const nextRows = rows.filter((_, index) => index !== rowIndex);
focusAfterRemoveRef.current = nextRows.length > 0 ? Math.min(rowIndex, nextRows.length - 1) : null;
onChange(nextRows);
};
return (
<div className="space-y-2">
{rows.length === 0 && (
<p className="text-[11px] text-text-muted">{t($ => $.properties.torrentWebSeedsEmpty)}</p>
)}
{rows.map((row, rowIndex) => {
const rowId = `${idPrefix}-${rowIndex}`;
const rowIsValid = normalizeTorrentWebSeedDrafts([row], filesForValidation) !== null;
return (
<div key={rowId} className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto] gap-2 items-end">
<div className="min-w-0">
<label htmlFor={`${rowId}-file`} className="block text-[10px] text-text-muted mb-1">
{t($ => $.properties.torrentWebSeedsFile)}
</label>
{files.length === 1 ? (
<select
id={`${rowId}-file`}
value={files[0].index}
onChange={() => undefined}
disabled
aria-invalid={!rowIsValid}
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-70"
>
<option value={files[0].index}>{files[0].index + 1}: {filePath(files[0])}</option>
</select>
) : (
<select
id={`${rowId}-file`}
value={row.fileIndex ?? ''}
onChange={event => updateRow(rowIndex, { fileIndex: Number(event.currentTarget.value) })}
disabled={disabled || files.length === 0}
aria-invalid={!rowIsValid}
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs disabled:opacity-50"
>
<option value="" disabled>{t($ => $.properties.torrentWebSeedsFile)}</option>
{files.map(file => (
<option key={file.index} value={file.index}>
{file.index + 1}: {filePath(file)}
</option>
))}
</select>
)}
</div>
<div className="min-w-0">
<label htmlFor={`${rowId}-uri`} className="block text-[10px] text-text-muted mb-1">
{t($ => $.properties.torrentWebSeedsUri)}
</label>
<input
id={`${rowId}-uri`}
type="url"
ref={element => { uriRefs.current[rowIndex] = element; }}
value={row.uri}
onChange={event => updateRow(rowIndex, { uri: event.currentTarget.value })}
disabled={disabled}
aria-invalid={!rowIsValid}
placeholder="https://mirror.example/torrent/"
className="app-control w-full min-h-[30px] px-2 py-1.5 text-xs font-mono disabled:opacity-50"
/>
</div>
<button
type="button"
onClick={() => removeRow(rowIndex)}
disabled={disabled}
aria-label={t($ => $.properties.torrentWebSeedsRemove)}
className="app-button min-h-[30px] px-2 text-xs disabled:opacity-50"
>
×
</button>
</div>
);
})}
{rows.length > 0 && !rowsAreValid && (
<p className="text-[11px] text-red-500" role="alert">
{t($ => $.properties.torrentWebSeedsInvalid)}
</p>
)}
<button
type="button"
onClick={addRow}
disabled={disabled || files.length === 0}
className="app-button px-3 text-xs disabled:opacity-50"
>
+ {t($ => $.properties.torrentWebSeedsAdd)}
</button>
</div>
);
};
+49 -6
View File
@@ -96,6 +96,7 @@ const common = {
completed: 'Completed',
failed: 'Failed',
retrying: 'Retrying',
moving: 'Moving data',
},
values: {
processing: 'Processing...',
@@ -288,11 +289,16 @@ const common = {
torrentPieceProgressSummary: '{{completed}} of {{total}} pieces complete · {{size}} each',
torrentPieceProgressMap: 'Torrent piece completion map',
torrentWebSeeds: 'Torrent web seeds',
torrentWebSeedsHint: 'One line per seed in the form file index|HTTP(S) URI. Firelink expands multi-file paths natively.',
torrentWebSeedsPlaceholder: '0|https://mirror.example/torrent/',
torrentWebSeedsHint: 'Add one HTTP(S) base URI per Torrent file. Firelink expands multi-file paths natively.',
torrentWebSeedsApply: 'Apply web seeds',
torrentWebSeedsLoading: 'Applying…',
torrentWebSeedsFailed: 'Could not validate or apply the Torrent web seeds.',
torrentWebSeedsEmpty: 'No web seeds configured.',
torrentWebSeedsFile: 'File',
torrentWebSeedsUri: 'HTTP(S) base URI',
torrentWebSeedsAdd: 'Add web seed',
torrentWebSeedsRemove: 'Remove web seed',
torrentWebSeedsInvalid: 'Each web-seed row needs a valid Torrent file and an HTTP(S) base URI without credentials or fragments.',
torrentPeerCount: '{{total}} peers · {{seeders}} seeders',
torrentPeerDownload: 'Download',
torrentPeerUpload: 'Upload',
@@ -300,12 +306,22 @@ const common = {
torrentPeerAmChoking: 'Firelink choking',
torrentPeerChoking: 'Peer choking',
torrentPeerShowing: 'Showing {{shown}} of {{total}} peers.',
torrentStatistics: 'Torrent statistics',
torrentUploaded: 'Uploaded',
torrentRatio: 'Ratio',
torrentSeededDuration: 'Seeded',
torrentConnectedPeers: 'Peers',
torrentSeeders: 'Seeders',
torrentUploadSpeed: 'Upload speed',
seconds: 'seconds',
torrentStopTimeout: 'Stop stalled Torrent after',
torrentStopTimeoutHint: 'Aria2 stops this Torrent after this many consecutive seconds at 0 B/s. 0 disables the policy; changes apply when the Torrent starts or retries.',
torrentStopTimeoutInvalid: 'Torrent stall timeout must be a whole number from 0 to 604800 seconds',
torrentPrioritizePiece: 'Prioritize Torrent pieces',
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.',
torrentPrioritizePiece: 'Prioritize first/last pieces for preview',
torrentPrioritizePieceHead: 'Prioritize first pieces',
torrentPrioritizePieceTail: 'Prioritize last pieces',
torrentPrioritizePieceSize: 'Preview piece range size',
torrentPrioritizePieceHint: 'Optional preview policy. Each enabled range defaults to 1M and applies 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',
@@ -313,6 +329,28 @@ const common = {
torrentFileAllocationNone: 'Allocate as needed',
torrentFileAllocationHint: 'Preallocation reserves the selected files before transfer. Allocation as needed avoids that upfront disk reservation.',
torrentDetails: 'Torrent details',
torrentCopyMagnet: 'Copy magnet link',
torrentExportMetadata: 'Export .torrent',
torrentMagnetCopied: 'Identity-only magnet copied.',
torrentMagnetCopyFailed: 'Could not copy the magnet link.',
torrentMetadataExported: 'Torrent metadata exported.',
torrentMetadataExportFailed: 'Could not export Torrent metadata.',
torrentMove: 'Move data…',
torrentMoveLoading: 'Moving…',
torrentMoveCancel: 'Cancel move',
torrentMoveCancelRequested: 'Canceling move…',
torrentMoveConfirm: 'Move the managed Torrent data to this folder? Existing files are never overwritten.',
torrentMoveCompleted: 'Torrent data moved.',
torrentMoveFailed: 'Could not move Torrent data.',
torrentAvailability: 'Swarm availability',
torrentAvailabilityRefresh: 'Refresh',
torrentAvailabilityLoading: 'Loading availability…',
torrentAvailabilityUnavailable: 'Availability is available for an active or paused Torrent.',
torrentAvailabilityFailed: 'Could not read Torrent availability.',
torrentAvailabilityHint: 'Only aggregate copy counts are shown; peer identities and raw bitfields are never exposed.',
torrentAvailabilitySummary: '{{availability}} copies available · {{peers}} connected peers · {{pieces}} pieces',
torrentAvailabilityMap: 'Torrent swarm availability map',
torrentAvailabilityBucket: 'At least {{copies}} copies in this range',
torrentDetailsLoading: 'Loading Torrent details…',
torrentDetailsUnavailable: 'Torrent details are not available.',
torrentDetailsDisplayName: 'Display name',
@@ -513,12 +551,14 @@ const common = {
moveOneFailed: 'Could not move download to queue',
copyAddressesFailed: 'Could not copy addresses',
copyAddressFailed: 'Could not copy address',
copyMagnetFailed: 'Could not copy the magnet link',
copyPathFailed: 'Could not copy file path',
missingFileName: 'File name is missing',
redownloadFailed: 'Redownload failed',
startResume: 'Start/Resume',
addToQueue: 'Add to Queue',
copyAddress: 'Copy Address',
copyMagnet: 'Copy magnet link',
remove: 'Remove',
open: 'Open',
showInFolder: 'Show in Folder',
@@ -603,8 +643,11 @@ const common = {
torrentStopTimeout: 'Stop stalled Torrent after',
torrentStopTimeoutHint: 'Aria2 stops this Torrent after this many consecutive seconds at 0 B/s. 0 disables the policy.',
torrentStopTimeoutInvalid: 'Torrent stall timeout must be a whole number from 0 to 604800 seconds',
torrentPrioritizePiece: 'Prioritize Torrent pieces',
torrentPrioritizePieceHint: 'Saved with this Torrent and applied on its next start or retry. Use head, tail, or both with optional K or M sizes.',
torrentPrioritizePiece: 'Prioritize first/last pieces for preview',
torrentPrioritizePieceHead: 'Prioritize first pieces',
torrentPrioritizePieceTail: 'Prioritize last pieces',
torrentPrioritizePieceSize: 'Preview piece range size',
torrentPrioritizePieceHint: 'Choose first pieces, last pieces, or both for preview. Each enabled range defaults to 1M and applies on the next start or retry.',
torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M',
torrentEncryptionPolicy: 'Torrent encryption policy',
torrentEncryptionPolicyHint: 'Saved with this Torrent and applied on its next start or retry. The selected policy keeps Aria2 encryption settings consistent.',
+49 -6
View File
@@ -96,6 +96,7 @@ const fa = {
completed: 'تکمیل‌شده',
failed: 'ناموفق',
retrying: 'در حال تلاش مجدد',
moving: 'در حال جابه‌جایی داده',
},
values: {
processing: 'در حال پردازش…',
@@ -288,11 +289,16 @@ const fa = {
torrentPieceProgressSummary: '{{completed}} از {{total}} قطعه کامل شده · هرکدام {{size}}',
torrentPieceProgressMap: 'نقشه تکمیل قطعه‌های تورنت',
torrentWebSeeds: 'وب‌سیدهای تورنت',
torrentWebSeedsHint: 'هر خط به‌شکل شماره فایل|نشانی HTTP(S). مسیر فایل‌های چندفایلی را Firelink در بخش native می‌سازد.',
torrentWebSeedsPlaceholder: '۰|https://mirror.example/torrent/',
torrentWebSeedsHint: 'برای هر فایل تورنت یک نشانی پایهٔ HTTP(S) اضافه کنید. Firelink مسیر فایل‌های چندفایلی را خودش می‌سازد.',
torrentWebSeedsApply: 'اعمال وب‌سیدها',
torrentWebSeedsLoading: 'در حال اعمال…',
torrentWebSeedsFailed: 'اعتبارسنجی یا اعمال وب‌سیدهای تورنت انجام نشد.',
torrentWebSeedsEmpty: 'وب‌سیدی تنظیم نشده است.',
torrentWebSeedsFile: 'فایل',
torrentWebSeedsUri: 'نشانی پایهٔ HTTP(S)',
torrentWebSeedsAdd: 'افزودن وب‌سید',
torrentWebSeedsRemove: 'حذف وب‌سید',
torrentWebSeedsInvalid: 'هر ردیف وب‌سید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.',
torrentPeerCount: '{{total}} همتا · {{seeders}} سید',
torrentPeerDownload: 'دریافت',
torrentPeerUpload: 'آپلود',
@@ -300,12 +306,22 @@ const fa = {
torrentPeerAmChoking: 'محدودسازی از طرف Firelink',
torrentPeerChoking: 'محدودسازی از طرف همتا',
torrentPeerShowing: 'نمایش {{shown}} همتا از {{total}} همتا.',
torrentStatistics: 'آمار تورنت',
torrentUploaded: 'آپلودشده',
torrentRatio: 'نسبت',
torrentSeededDuration: 'مدت سید',
torrentConnectedPeers: 'همتاها',
torrentSeeders: 'سیدها',
torrentUploadSpeed: 'سرعت آپلود',
seconds: 'ثانیه',
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف می‌کند. ۰ این سیاست را غیرفعال می‌کند؛ تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.',
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
torrentPrioritizePiece: 'اولویت‌بندی قطعه‌های تورنت',
torrentPrioritizePieceHint: 'سیاست اختیاری پیش‌نمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام می‌توان اندازه‌ای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.',
torrentPrioritizePiece: 'اولویت دادن به قطعه‌های ابتدا/انتها برای پیش‌نمایش',
torrentPrioritizePieceHead: 'اولویت قطعه‌های ابتدا',
torrentPrioritizePieceTail: 'اولویت قطعه‌های انتها',
torrentPrioritizePieceSize: 'اندازهٔ بازهٔ پیش‌نمایش',
torrentPrioritizePieceHint: 'سیاست اختیاری پیش‌نمایش. هر بازهٔ فعال به‌طور پیش‌فرض 1M است و هنگام شروع یا تلاش مجدد اعمال می‌شود.',
torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
torrentFileAllocation: 'نحوهٔ تخصیص فایل تورنت',
@@ -313,6 +329,28 @@ const fa = {
torrentFileAllocationNone: 'تخصیص هنگام نیاز',
torrentFileAllocationHint: 'تخصیص از پیش فضای فایل‌های انتخاب‌شده را قبل از انتقال رزرو می‌کند؛ تخصیص هنگام نیاز این رزرو اولیه را انجام نمی‌دهد.',
torrentDetails: 'جزئیات تورنت',
torrentCopyMagnet: 'کپی پیوند مگنت',
torrentExportMetadata: 'خروجی .torrent',
torrentMagnetCopied: 'مگنت فقط-هویتی کپی شد.',
torrentMagnetCopyFailed: 'کپی پیوند مگنت ناموفق بود.',
torrentMetadataExported: 'فراداده تورنت خروجی گرفته شد.',
torrentMetadataExportFailed: 'خروجی فراداده تورنت ناموفق بود.',
torrentMove: 'جابه‌جایی داده…',
torrentMoveLoading: 'در حال جابه‌جایی…',
torrentMoveCancel: 'لغو جابه‌جایی',
torrentMoveCancelRequested: 'درخواست لغو جابه‌جایی ارسال شد…',
torrentMoveConfirm: 'داده تورنت مدیریت‌شده به این پوشه منتقل شود؟ فایل‌های موجود هرگز بازنویسی نمی‌شوند.',
torrentMoveCompleted: 'داده تورنت جابه‌جا شد.',
torrentMoveFailed: 'جابه‌جایی داده تورنت ناموفق بود.',
torrentAvailability: 'دسترس‌پذیری شبکه',
torrentAvailabilityRefresh: 'تازه‌سازی',
torrentAvailabilityLoading: 'در حال بارگیری دسترس‌پذیری…',
torrentAvailabilityUnavailable: 'دسترس‌پذیری برای تورنت فعال یا متوقف در دسترس است.',
torrentAvailabilityFailed: 'خواندن دسترس‌پذیری تورنت ناموفق بود.',
torrentAvailabilityHint: 'فقط شمارش کلی کپی‌ها نمایش داده می‌شود؛ هویت همتاها و بیت‌فیلد خام هرگز نمایش داده نمی‌شوند.',
torrentAvailabilitySummary: '{{availability}} کپی در دسترس · {{peers}} همتای متصل · {{pieces}} قطعه',
torrentAvailabilityMap: 'نقشه دسترس‌پذیری شبکه تورنت',
torrentAvailabilityBucket: 'حداقل {{copies}} کپی در این بازه',
torrentDetailsLoading: 'در حال دریافت جزئیات تورنت…',
torrentDetailsUnavailable: 'جزئیات تورنت در دسترس نیست.',
torrentDetailsDisplayName: 'نام نمایشی',
@@ -513,12 +551,14 @@ const fa = {
moveOneFailed: 'انتقال دانلود به صف ناموفق بود',
copyAddressesFailed: 'آدرس‌ها کپی نشدند',
copyAddressFailed: 'آدرس کپی نشد',
copyMagnetFailed: 'پیوند مگنت کپی نشد',
copyPathFailed: 'مسیر فایل کپی نشد',
missingFileName: 'نام فایل وجود ندارد',
redownloadFailed: 'دانلود مجدد ناموفق بود',
startResume: 'شروع/ادامه',
addToQueue: 'افزودن به صف',
copyAddress: 'کپی آدرس',
copyMagnet: 'کپی پیوند مگنت',
remove: 'حذف',
open: 'باز کردن',
showInFolder: 'نمایش در پوشه',
@@ -603,8 +643,11 @@ const fa = {
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف می‌کند. ۰ این سیاست را غیرفعال می‌کند.',
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
torrentPrioritizePiece: 'اولویت‌بندی قطعه‌های تورنت',
torrentPrioritizePieceHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود. ابتدا، انتها یا هر دو را با اندازه اختیاری K یا M وارد کنید.',
torrentPrioritizePiece: 'اولویت دادن به قطعه‌های ابتدا/انتها برای پیش‌نمایش',
torrentPrioritizePieceHead: 'اولویت قطعه‌های ابتدا',
torrentPrioritizePieceTail: 'اولویت قطعه‌های انتها',
torrentPrioritizePieceSize: 'اندازهٔ بازهٔ پیش‌نمایش',
torrentPrioritizePieceHint: 'قطعه‌های ابتدا، انتها یا هر دو را برای پیش‌نمایش انتخاب کنید. هر بازهٔ فعال به‌طور پیش‌فرض 1M است و در شروع یا تلاش مجدد بعدی اعمال می‌شود.',
torrentPrioritizePieceInvalid: 'اولویت قطعه‌های تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
torrentEncryptionPolicyHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود. سیاست انتخابی تنظیمات رمزنگاری آریا۲ را سازگار نگه می‌دارد.',
+49 -6
View File
@@ -96,6 +96,7 @@ const he = {
completed: 'הושלם',
failed: 'נכשל',
retrying: 'ניסיון חוזר',
moving: 'מעביר נתונים',
},
values: {
processing: 'מעבד…',
@@ -288,11 +289,16 @@ const he = {
torrentPieceProgressSummary: '{{completed}} מתוך {{total}} חלקים הושלמו · {{size}} לכל חלק',
torrentPieceProgressMap: 'מפת השלמת חלקי הטורנט',
torrentWebSeeds: 'זריעות Web של טורנט',
torrentWebSeedsHint: 'שורה אחת לכל זרע בפורמט file index|כתובת HTTP(S). Firelink מרחיב נתיבי קבצים מרובי-קבצים באופן מקורי.',
torrentWebSeedsPlaceholder: '0|https://מראה.example/torrent/',
torrentWebSeedsHint: 'הוסף כתובת בסיס HTTP(S) אחת לכל קובץ טורנט. Firelink מרחיב נתיבים של טורנטים מרובי-קבצים.',
torrentWebSeedsApply: 'החל זריעות Web',
torrentWebSeedsLoading: 'מיישם…',
torrentWebSeedsFailed: 'לא ניתן לאמת או להחיל את זריעות ה-Web של הטורנט.',
torrentWebSeedsEmpty: 'לא הוגדרו זריעות Web.',
torrentWebSeedsFile: 'קובץ',
torrentWebSeedsUri: 'כתובת בסיס HTTP(S)',
torrentWebSeedsAdd: 'הוסף זריעת Web',
torrentWebSeedsRemove: 'הסר זריעת Web',
torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.',
torrentPeerCount: '{{total}} עמיתים · {{seeders}} משתפים',
torrentPeerDownload: 'הורדה',
torrentPeerUpload: 'העלאה',
@@ -300,12 +306,22 @@ const he = {
torrentPeerAmChoking: 'Firelink מגביל',
torrentPeerChoking: 'העמית מגביל',
torrentPeerShowing: 'מוצגים {{shown}} מתוך {{total}} עמיתים.',
torrentStatistics: 'סטטיסטיקות טורנט',
torrentUploaded: 'הועלה',
torrentRatio: 'יחס',
torrentSeededDuration: 'משך שיתוף',
torrentConnectedPeers: 'עמיתים',
torrentSeeders: 'משתפים',
torrentUploadSpeed: 'מהירות העלאה',
seconds: 'שניות',
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות; השינוי חל כשהטורנט מתחיל או מנסה שוב.',
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.',
torrentPrioritizePiece: 'תעדוף החלקים הראשונים/האחרונים לתצוגה מקדימה',
torrentPrioritizePieceHead: 'תעדף חלקים ראשונים',
torrentPrioritizePieceTail: 'תעדף חלקים אחרונים',
torrentPrioritizePieceSize: 'גודל טווח התצוגה המקדימה',
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית. כל טווח מופעל מתחיל ב־1M ומוחל כשהטורנט מופעל או מנסה שוב.',
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
torrentFileAllocation: 'הקצאת קובצי Torrent',
@@ -313,6 +329,28 @@ const he = {
torrentFileAllocationNone: 'הקצאה לפי הצורך',
torrentFileAllocationHint: 'הקצאה מראש שומרת מקום לקבצים לפני ההעברה; הקצאה לפי הצורך נמנעת מהשמירה הראשונית.',
torrentDetails: 'פרטי טורנט',
torrentCopyMagnet: 'העתקת קישור מגנט',
torrentExportMetadata: 'ייצוא .torrent',
torrentMagnetCopied: 'קישור מגנט זהותי הועתק.',
torrentMagnetCopyFailed: 'לא ניתן להעתיק את קישור המגנט.',
torrentMetadataExported: 'מטא־נתוני הטורנט יוצאו.',
torrentMetadataExportFailed: 'לא ניתן לייצא את מטא־נתוני הטורנט.',
torrentMove: 'העברת נתונים…',
torrentMoveLoading: 'מעביר…',
torrentMoveCancel: 'ביטול ההעברה',
torrentMoveCancelRequested: 'בקשת ביטול נשלחה…',
torrentMoveConfirm: 'להעביר את נתוני הטורנט המנוהלים לתיקייה זו? קבצים קיימים לעולם לא יוחלפו.',
torrentMoveCompleted: 'נתוני הטורנט הועברו.',
torrentMoveFailed: 'לא ניתן להעביר את נתוני הטורנט.',
torrentAvailability: 'זמינות הנחיל',
torrentAvailabilityRefresh: 'רענון',
torrentAvailabilityLoading: 'טוען זמינות…',
torrentAvailabilityUnavailable: 'הזמינות זמינה עבור טורנט פעיל או מושהה.',
torrentAvailabilityFailed: 'לא ניתן לקרוא את זמינות הטורנט.',
torrentAvailabilityHint: 'מוצגים רק מספרי עותקים מצטברים; זהויות עמיתים וביטפילדים גולמיים לעולם אינם נחשפים.',
torrentAvailabilitySummary: '{{availability}} עותקים זמינים · {{peers}} עמיתים מחוברים · {{pieces}} חלקים',
torrentAvailabilityMap: 'מפת זמינות נחיל הטורנט',
torrentAvailabilityBucket: 'לפחות {{copies}} עותקים בטווח זה',
torrentDetailsLoading: 'טוען פרטי טורנט…',
torrentDetailsUnavailable: 'פרטי הטורנט אינם זמינים.',
torrentDetailsDisplayName: 'שם תצוגה',
@@ -513,12 +551,14 @@ const he = {
moveOneFailed: 'לא ניתן להעביר הורדה לתור',
copyAddressesFailed: 'לא ניתן להעתיק כתובות',
copyAddressFailed: 'לא ניתן להעתיק כתובת',
copyMagnetFailed: 'לא ניתן להעתיק את קישור המגנט',
copyPathFailed: 'לא ניתן להעתיק נתיב קובץ',
missingFileName: 'שם הקובץ חסר',
redownloadFailed: 'הורדה מחדש נכשלה',
startResume: 'הפעלה/חידוש',
addToQueue: 'הוספה לתור',
copyAddress: 'העתקת כתובת',
copyMagnet: 'העתקת קישור מגנט',
remove: 'הסרה',
open: 'פתיחה',
showInFolder: 'הצגה בתיקייה',
@@ -603,8 +643,11 @@ const he = {
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות.',
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
torrentPrioritizePieceHint: 'נשמר עם הטורנט ומוחל בהפעלה או בניסיון חוזר. יש להזין התחלה, סוף או שניהם עם גודל K או M אופציונלי.',
torrentPrioritizePiece: 'תעדוף החלקים הראשונים/האחרונים לתצוגה מקדימה',
torrentPrioritizePieceHead: 'תעדף חלקים ראשונים',
torrentPrioritizePieceTail: 'תעדף חלקים אחרונים',
torrentPrioritizePieceSize: 'גודל טווח התצוגה המקדימה',
torrentPrioritizePieceHint: 'בחר חלקים ראשונים, אחרונים או את שניהם לתצוגה מקדימה. כל טווח מופעל מתחיל ב־1M ומוחל בהפעלה או בניסיון חוזר הבא.',
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
torrentEncryptionPolicyHint: 'נשמרת עם ה-Torrent ומוחלת בהפעלה או בניסיון חוזר. המדיניות שומרת על הגדרות ההצפנה של Aria2 עקביות.',
+49 -6
View File
@@ -96,6 +96,7 @@ const ru = {
completed: 'Завершено',
failed: 'Ошибка',
retrying: 'Повторная попытка',
moving: 'Перемещение данных',
},
values: {
processing: 'Обработка…',
@@ -288,11 +289,16 @@ const ru = {
torrentPieceProgressSummary: '{{completed}} из {{total}} частей завершено · по {{size}} на часть',
torrentPieceProgressMap: 'Карта завершения частей торрента',
torrentWebSeeds: 'Веб-сиды торрента',
torrentWebSeedsHint: 'Одна строка на сид в формате индекс файла|HTTP(S)-URI. Firelink сам расширяет пути многофайловых торрентов.',
torrentWebSeedsPlaceholder: '0|https://зеркало.example/torrent/',
torrentWebSeedsHint: 'Добавьте по одному базовому HTTP(S)-адресу для каждого файла торрента. Firelink сам расширит пути многофайлового торрента.',
torrentWebSeedsApply: 'Применить веб-сиды',
torrentWebSeedsLoading: 'Применение…',
torrentWebSeedsFailed: 'Не удалось проверить или применить веб-сиды торрента.',
torrentWebSeedsEmpty: 'Веб-сиды не настроены.',
torrentWebSeedsFile: 'Файл',
torrentWebSeedsUri: 'Базовый HTTP(S)-адрес',
torrentWebSeedsAdd: 'Добавить веб-сид',
torrentWebSeedsRemove: 'Удалить веб-сид',
torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.',
torrentPeerCount: '{{total}} пиров · {{seeders}} сидеров',
torrentPeerDownload: 'Загрузка',
torrentPeerUpload: 'Отдача',
@@ -300,12 +306,22 @@ const ru = {
torrentPeerAmChoking: 'Firelink ограничивает',
torrentPeerChoking: 'Пир ограничивает',
torrentPeerShowing: 'Показано {{shown}} из {{total}} пиров.',
torrentStatistics: 'Статистика торрента',
torrentUploaded: 'Отдано',
torrentRatio: 'Коэффициент',
torrentSeededDuration: 'Время раздачи',
torrentConnectedPeers: 'Пиры',
torrentSeeders: 'Сиды',
torrentUploadSpeed: 'Скорость отдачи',
seconds: 'секунд',
torrentStopTimeout: 'Останавливать неактивный торрент через',
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило; изменения применяются при запуске или повторной попытке.',
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
torrentPrioritizePiece: 'Приоритет частей торрента',
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.',
torrentPrioritizePiece: 'Приоритет первых/последних частей для предпросмотра',
torrentPrioritizePieceHead: 'Приоритет первых частей',
torrentPrioritizePieceTail: 'Приоритет последних частей',
torrentPrioritizePieceSize: 'Размер диапазона предпросмотра',
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра. Каждый включённый диапазон по умолчанию равен 1M и применяется при запуске или повторной попытке.',
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
torrentEncryptionPolicy: 'Политика шифрования Torrent',
torrentFileAllocation: 'Выделение места для файлов Torrent',
@@ -314,6 +330,28 @@ const ru = {
torrentFileAllocationHint: 'Предварительное выделение резервирует место до передачи; выделение по мере необходимости не делает начальное резервирование.',
torrentEncryptionPolicyHint: 'Применяется при запуске или повторной попытке Torrent. Выберите одну политику, чтобы параметры handshake и шифрования payload оставались согласованными.',
torrentDetails: 'Сведения о Torrent',
torrentCopyMagnet: 'Копировать magnet-ссылку',
torrentExportMetadata: 'Экспортировать .torrent',
torrentMagnetCopied: 'Magnet-ссылка только с идентификатором скопирована.',
torrentMagnetCopyFailed: 'Не удалось скопировать magnet-ссылку.',
torrentMetadataExported: 'Метаданные Torrent экспортированы.',
torrentMetadataExportFailed: 'Не удалось экспортировать метаданные Torrent.',
torrentMove: 'Переместить данные…',
torrentMoveLoading: 'Перемещение…',
torrentMoveCancel: 'Отменить перемещение',
torrentMoveCancelRequested: 'Запрос на отмену отправлен…',
torrentMoveConfirm: 'Переместить управляемые данные Torrent в эту папку? Существующие файлы не перезаписываются.',
torrentMoveCompleted: 'Данные Torrent перемещены.',
torrentMoveFailed: 'Не удалось переместить данные Torrent.',
torrentAvailability: 'Доступность раздачи',
torrentAvailabilityRefresh: 'Обновить',
torrentAvailabilityLoading: 'Загрузка доступности…',
torrentAvailabilityUnavailable: 'Доступность доступна для активного или приостановленного Torrent.',
torrentAvailabilityFailed: 'Не удалось прочитать доступность Torrent.',
torrentAvailabilityHint: 'Показываются только агрегированные копии; идентификаторы пиров и исходные битовые поля не раскрываются.',
torrentAvailabilitySummary: '{{availability}} доступных копий · {{peers}} подключённых пиров · {{pieces}} частей',
torrentAvailabilityMap: 'Карта доступности раздачи Torrent',
torrentAvailabilityBucket: 'Не менее {{copies}} копий в этом диапазоне',
torrentDetailsLoading: 'Загрузка сведений о Torrent…',
torrentDetailsUnavailable: 'Сведения о Torrent недоступны.',
torrentDetailsDisplayName: 'Отображаемое имя',
@@ -513,12 +551,14 @@ const ru = {
moveOneFailed: 'Не удалось переместить загрузку в очередь',
copyAddressesFailed: 'Не удалось скопировать адреса',
copyAddressFailed: 'Не удалось скопировать адрес',
copyMagnetFailed: 'Не удалось скопировать magnet-ссылку',
copyPathFailed: 'Не удалось скопировать путь к файлу',
missingFileName: 'Отсутствует имя файла',
redownloadFailed: 'Не удалось скачать повторно',
startResume: 'Запустить/Возобновить',
addToQueue: 'Добавить в очередь',
copyAddress: 'Скопировать адрес',
copyMagnet: 'Копировать magnet-ссылку',
remove: 'Удалить',
open: 'Открыть',
showInFolder: 'Показать в папке',
@@ -603,8 +643,11 @@ const ru = {
torrentStopTimeout: 'Останавливать неактивный торрент через',
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило.',
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
torrentPrioritizePiece: 'Приоритет частей торрента',
torrentPrioritizePieceHint: 'Сохраняется с торрентом и применяется при следующем запуске или повторной попытке. Укажите начало, конец или оба варианта с размером K или M.',
torrentPrioritizePiece: 'Приоритет первых/последних частей для предпросмотра',
torrentPrioritizePieceHead: 'Приоритет первых частей',
torrentPrioritizePieceTail: 'Приоритет последних частей',
torrentPrioritizePieceSize: 'Размер диапазона предпросмотра',
torrentPrioritizePieceHint: 'Выберите первые, последние части или оба варианта для предпросмотра. Каждый включённый диапазон по умолчанию равен 1M и применяется при следующем запуске или повторной попытке.',
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
torrentEncryptionPolicy: 'Политика шифрования Torrent',
torrentEncryptionPolicyHint: 'Сохраняется вместе с Torrent и применяется при следующем запуске или повторной попытке. Выбранная политика согласует параметры шифрования Aria2.',
+49 -6
View File
@@ -96,6 +96,7 @@ const uk = {
completed: 'Завершено',
failed: 'Помилка',
retrying: 'Повторна спроба',
moving: 'Переміщення даних',
},
values: {
processing: 'Обробка…',
@@ -288,11 +289,16 @@ const uk = {
torrentPieceProgressSummary: '{{completed}} із {{total}} частин завершено · по {{size}} на частину',
torrentPieceProgressMap: 'Карта завершення частин торрента',
torrentWebSeeds: 'Вебсіди торента',
torrentWebSeedsHint: 'Один рядок на сід у форматі індекс файлу|HTTP(S)-URI. Firelink сам розгортає шляхи багатофайлових торентів.',
torrentWebSeedsPlaceholder: '0|https://дзеркало.example/torrent/',
torrentWebSeedsHint: 'Додайте одну базову HTTP(S)-адресу для кожного файлу торента. Firelink сам розгорне шляхи багатофайлового торента.',
torrentWebSeedsApply: 'Застосувати вебсіди',
torrentWebSeedsLoading: 'Застосування…',
torrentWebSeedsFailed: 'Не вдалося перевірити або застосувати вебсіди торента.',
torrentWebSeedsEmpty: 'Вебсіди не налаштовано.',
torrentWebSeedsFile: 'Файл',
torrentWebSeedsUri: 'Базова HTTP(S)-адреса',
torrentWebSeedsAdd: 'Додати вебсід',
torrentWebSeedsRemove: 'Видалити вебсід',
torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.',
torrentPeerCount: '{{total}} пірів · {{seeders}} сідів',
torrentPeerDownload: 'Завантаження',
torrentPeerUpload: 'Віддача',
@@ -300,12 +306,22 @@ const uk = {
torrentPeerAmChoking: 'Firelink обмежує',
torrentPeerChoking: 'Пір обмежує',
torrentPeerShowing: 'Показано {{shown}} із {{total}} пірів.',
torrentStatistics: 'Статистика торента',
torrentUploaded: 'Віддано',
torrentRatio: 'Коефіцієнт',
torrentSeededDuration: 'Час роздачі',
torrentConnectedPeers: 'Піри',
torrentSeeders: 'Сіди',
torrentUploadSpeed: 'Швидкість віддачі',
seconds: 'секунд',
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило; зміни застосовуються під час запуску або повторної спроби.',
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
torrentPrioritizePiece: 'Пріоритет частин торрента',
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.',
torrentPrioritizePiece: 'Пріоритет перших/останніх частин для попереднього перегляду',
torrentPrioritizePieceHead: 'Пріоритет перших частин',
torrentPrioritizePieceTail: 'Пріоритет останніх частин',
torrentPrioritizePieceSize: 'Розмір діапазону попереднього перегляду',
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду. Кожен увімкнений діапазон за замовчуванням має розмір 1M і застосовується під час запуску або повторної спроби.',
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
torrentEncryptionPolicy: 'Політика шифрування Torrent',
torrentFileAllocation: 'Виділення місця для файлів Torrent',
@@ -314,6 +330,28 @@ const uk = {
torrentFileAllocationHint: 'Попереднє виділення резервує місце до передачі; виділення за потреби не робить початкового резервування.',
torrentEncryptionPolicyHint: 'Застосовується під час запуску або повторної спроби Torrent. Виберіть одну політику, щоб параметри handshake і шифрування payload залишалися узгодженими.',
torrentDetails: 'Відомості про Torrent',
torrentCopyMagnet: 'Копіювати magnet-посилання',
torrentExportMetadata: 'Експортувати .torrent',
torrentMagnetCopied: 'Magnet-посилання лише з ідентифікатором скопійовано.',
torrentMagnetCopyFailed: 'Не вдалося скопіювати magnet-посилання.',
torrentMetadataExported: 'Метадані Torrent експортовано.',
torrentMetadataExportFailed: 'Не вдалося експортувати метадані Torrent.',
torrentMove: 'Перемістити дані…',
torrentMoveLoading: 'Переміщення…',
torrentMoveCancel: 'Скасувати переміщення',
torrentMoveCancelRequested: 'Запит на скасування надіслано…',
torrentMoveConfirm: 'Перемістити керовані дані Torrent до цієї папки? Наявні файли не перезаписуються.',
torrentMoveCompleted: 'Дані Torrent переміщено.',
torrentMoveFailed: 'Не вдалося перемістити дані Torrent.',
torrentAvailability: 'Доступність рою',
torrentAvailabilityRefresh: 'Оновити',
torrentAvailabilityLoading: 'Завантаження доступності…',
torrentAvailabilityUnavailable: 'Доступність доступна для активного або призупиненого Torrent.',
torrentAvailabilityFailed: 'Не вдалося прочитати доступність Torrent.',
torrentAvailabilityHint: 'Показано лише агреговані копії; ідентифікатори пірів і сирі бітові поля не розкриваються.',
torrentAvailabilitySummary: '{{availability}} доступних копій · {{peers}} підключених пірів · {{pieces}} частин',
torrentAvailabilityMap: 'Мапа доступності рою Torrent',
torrentAvailabilityBucket: 'Щонайменше {{copies}} копій у цьому діапазоні',
torrentDetailsLoading: 'Завантаження відомостей про Torrent…',
torrentDetailsUnavailable: 'Відомості про Torrent недоступні.',
torrentDetailsDisplayName: 'Назва',
@@ -513,12 +551,14 @@ const uk = {
moveOneFailed: 'Не вдалося перемістити завантаження до черги',
copyAddressesFailed: 'Не вдалося скопіювати адреси',
copyAddressFailed: 'Не вдалося скопіювати адресу',
copyMagnetFailed: 'Не вдалося скопіювати magnet-посилання',
copyPathFailed: 'Не вдалося скопіювати шлях до файлу',
missingFileName: 'Ім\'я файлу відсутнє',
redownloadFailed: 'Не вдалося повторно завантажити',
startResume: 'Запустити/Відновити',
addToQueue: 'Додати до черги',
copyAddress: 'Скопіювати адресу',
copyMagnet: 'Копіювати magnet-посилання',
remove: 'Видалити',
open: 'Відкрити',
showInFolder: 'Показати в папці',
@@ -603,8 +643,11 @@ const uk = {
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило.',
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
torrentPrioritizePiece: 'Пріоритет частин торрента',
torrentPrioritizePieceHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. Укажіть початок, кінець або обидва варіанти з розміром K чи M.',
torrentPrioritizePiece: 'Пріоритет перших/останніх частин для попереднього перегляду',
torrentPrioritizePieceHead: 'Пріоритет перших частин',
torrentPrioritizePieceTail: 'Пріоритет останніх частин',
torrentPrioritizePieceSize: 'Розмір діапазону попереднього перегляду',
torrentPrioritizePieceHint: 'Виберіть перші, останні частини або обидва варіанти для попереднього перегляду. Кожен увімкнений діапазон за замовчуванням має розмір 1M і застосовується під час наступного запуску або повторної спроби.',
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
torrentEncryptionPolicy: 'Політика шифрування Torrent',
torrentEncryptionPolicyHint: 'Зберігається разом із Torrent і застосовується під час наступного запуску або повторної спроби. Вибрана політика узгоджує параметри шифрування Aria2.',
+49 -6
View File
@@ -96,6 +96,7 @@ const zhCN = {
completed: '已完成',
failed: '失败',
retrying: '重试中',
moving: '正在移动数据',
},
values: {
processing: '处理中…',
@@ -288,11 +289,16 @@ const zhCN = {
torrentPieceProgressSummary: '{{completed}}/{{total}} 个分片已完成 · 每片 {{size}}',
torrentPieceProgressMap: 'Torrent 分片完成度地图',
torrentWebSeeds: 'Torrent Web 做种',
torrentWebSeedsHint: '每行一个做种,格式为文件索引|HTTP(S) URI。多文件路径由 Firelink 原生展开。',
torrentWebSeedsPlaceholder: '0|https://镜像.example/torrent/',
torrentWebSeedsHint: '为每个 Torrent 文件添加一个 HTTP(S) 基础地址。多文件路径由 Firelink 原生展开。',
torrentWebSeedsApply: '应用 Web 做种',
torrentWebSeedsLoading: '正在应用…',
torrentWebSeedsFailed: '无法验证或应用 Torrent Web 做种。',
torrentWebSeedsEmpty: '尚未配置 Web 做种。',
torrentWebSeedsFile: '文件',
torrentWebSeedsUri: 'HTTP(S) 基础地址',
torrentWebSeedsAdd: '添加 Web 做种',
torrentWebSeedsRemove: '移除 Web 做种',
torrentWebSeedsInvalid: '每行 Web 做种都需要有效的 Torrent 文件和不含凭据或片段的 HTTP(S) 基础地址。',
torrentPeerCount: '{{total}} 个节点 · {{seeders}} 个做种节点',
torrentPeerDownload: '下载',
torrentPeerUpload: '上传',
@@ -300,12 +306,22 @@ const zhCN = {
torrentPeerAmChoking: 'Firelink 限制中',
torrentPeerChoking: '对等节点限制中',
torrentPeerShowing: '显示 {{total}} 个节点中的 {{shown}} 个。',
torrentStatistics: '种子统计',
torrentUploaded: '已上传',
torrentRatio: '分享率',
torrentSeededDuration: '做种时长',
torrentConnectedPeers: '连接数',
torrentSeeders: '种子数',
torrentUploadSpeed: '上传速度',
seconds: '秒',
torrentStopTimeout: '在此时间后停止无速度 Torrent',
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用;更改会在 Torrent 启动或重试时应用。',
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
torrentPrioritizePiece: '优先下载 Torrent 片段',
torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。',
torrentPrioritizePiece: '为预览优先下载开头/结尾片段',
torrentPrioritizePieceHead: '优先下载开头片段',
torrentPrioritizePieceTail: '优先下载结尾片段',
torrentPrioritizePieceSize: '预览片段范围大小',
torrentPrioritizePieceHint: '可选的预览策略。每个启用的范围默认为 1M,并在 Torrent 启动或重试时应用。',
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
torrentEncryptionPolicy: 'Torrent 加密策略',
torrentFileAllocation: 'Torrent 文件分配',
@@ -314,6 +330,28 @@ const zhCN = {
torrentFileAllocationHint: '预分配会在传输前为选中文件预留空间;按需分配不会进行初始磁盘预留。',
torrentEncryptionPolicyHint: '在 Torrent 启动或重试时应用。选择单一策略,确保握手和 payload 加密设置保持一致。',
torrentDetails: 'Torrent 详细信息',
torrentCopyMagnet: '复制磁力链接',
torrentExportMetadata: '导出 .torrent',
torrentMagnetCopied: '仅包含身份信息的磁力链接已复制。',
torrentMagnetCopyFailed: '无法复制磁力链接。',
torrentMetadataExported: 'Torrent 元数据已导出。',
torrentMetadataExportFailed: '无法导出 Torrent 元数据。',
torrentMove: '移动数据…',
torrentMoveLoading: '正在移动…',
torrentMoveCancel: '取消移动',
torrentMoveCancelRequested: '已请求取消移动…',
torrentMoveConfirm: '要将托管的 Torrent 数据移动到此文件夹吗?不会覆盖现有文件。',
torrentMoveCompleted: 'Torrent 数据已移动。',
torrentMoveFailed: '无法移动 Torrent 数据。',
torrentAvailability: '种群可用性',
torrentAvailabilityRefresh: '刷新',
torrentAvailabilityLoading: '正在加载可用性…',
torrentAvailabilityUnavailable: '活动或暂停的 Torrent 可查看可用性。',
torrentAvailabilityFailed: '无法读取 Torrent 可用性。',
torrentAvailabilityHint: '仅显示聚合副本数量;不会暴露节点身份或原始位字段。',
torrentAvailabilitySummary: '{{availability}} 个可用副本 · {{peers}} 个已连接节点 · {{pieces}} 个分片',
torrentAvailabilityMap: 'Torrent 种群可用性图',
torrentAvailabilityBucket: '此范围至少有 {{copies}} 个副本',
torrentDetailsLoading: '正在加载 Torrent 详细信息…',
torrentDetailsUnavailable: 'Torrent 详细信息不可用。',
torrentDetailsDisplayName: '显示名称',
@@ -513,12 +551,14 @@ const zhCN = {
moveOneFailed: '无法将下载移动到队列',
copyAddressesFailed: '无法复制地址',
copyAddressFailed: '无法复制地址',
copyMagnetFailed: '无法复制磁力链接',
copyPathFailed: '无法复制文件路径',
missingFileName: '缺少文件名',
redownloadFailed: '重新下载失败',
startResume: '开始/恢复',
addToQueue: '添加到队列',
copyAddress: '复制地址',
copyMagnet: '复制磁力链接',
remove: '移除',
open: '打开',
showInFolder: '在文件夹中显示',
@@ -603,8 +643,11 @@ const zhCN = {
torrentStopTimeout: '在此时间后停止无速度 Torrent',
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用。',
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
torrentPrioritizePiece: '优先下载 Torrent 片段',
torrentPrioritizePieceHint: '随 Torrent 保存,并在下次启动或重试时应用。可使用开头、结尾或两者,并可选 K 或 M 大小。',
torrentPrioritizePiece: '为预览优先下载开头/结尾片段',
torrentPrioritizePieceHead: '优先下载开头片段',
torrentPrioritizePieceTail: '优先下载结尾片段',
torrentPrioritizePieceSize: '预览片段范围大小',
torrentPrioritizePieceHint: '选择开头片段、结尾片段或两者用于预览。每个启用的范围默认为 1M,并在下次启动或重试时应用。',
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
torrentEncryptionPolicy: 'Torrent 加密策略',
torrentEncryptionPolicyHint: '随 Torrent 保存,并在下次启动或重试时应用。所选策略会保持 Aria2 加密设置一致。',
+7
View File
@@ -25,6 +25,7 @@ import type { TorrentPieceProgressSnapshot } from './bindings/TorrentPieceProgre
import type { TorrentWebSeed } from './bindings/TorrentWebSeed';
import type { TorrentDetails } from './bindings/TorrentDetails';
import type { TorrentFileSelectionSnapshot } from './bindings/TorrentFileSelectionSnapshot';
import type { TorrentAvailabilitySnapshot } from './bindings/TorrentAvailabilitySnapshot';
type CommandMap = {
fetch_metadata: {
@@ -89,7 +90,12 @@ type CommandMap = {
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 };
get_torrent_availability: { args: { id: string }; result: TorrentAvailabilitySnapshot };
verify_torrent_data: { args: { id: string }; result: void };
get_torrent_magnet_link: { args: { id: string }; result: string };
export_torrent_metadata: { args: { id: string; destination: string }; result: void };
move_torrent_data: { args: { id: string; destination: string }; result: void };
cancel_torrent_move_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 };
@@ -169,6 +175,7 @@ type EventMap = {
'schedule-trigger': { action: 'start' | 'stop'; key: string };
'download-progress': DownloadProgressEvent;
'download-state': DownloadStateEvent;
'torrent-move-progress': import('./bindings/TorrentMoveProgressEvent').TorrentMoveProgressEvent;
'download-complete': string;
'download-failed': string;
'extension-add-download': ExtensionDownload;
+19 -2
View File
@@ -3,12 +3,16 @@ import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
interface DownloadProgressState {
progressMap: Record<string, DownloadProgressEvent>;
moveProgressMap: Record<string, number>;
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
clearDownloadProgress: (id: string) => void;
setMoveProgress: (id: string, fraction: number) => void;
clearMoveProgress: (id: string) => void;
}
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
progressMap: {},
moveProgressMap: {},
updateDownloadProgress: (id, payload) =>
set((state) => ({
progressMap: {
@@ -18,9 +22,22 @@ export const useDownloadProgressStore = create<DownloadProgressState>((set) => (
})),
clearDownloadProgress: (id) =>
set((state) => {
if (!(id in state.progressMap)) return state;
if (!(id in state.progressMap) && !(id in state.moveProgressMap)) return state;
const next = { ...state.progressMap };
delete next[id];
return { progressMap: next };
const nextMove = { ...state.moveProgressMap };
delete nextMove[id];
return { progressMap: next, moveProgressMap: nextMove };
}),
setMoveProgress: (id, fraction) =>
set((state) => ({
moveProgressMap: { ...state.moveProgressMap, [id]: fraction }
})),
clearMoveProgress: (id) =>
set((state) => {
if (!(id in state.moveProgressMap)) return state;
const next = { ...state.moveProgressMap };
delete next[id];
return { moveProgressMap: next };
}),
}));
+3 -3
View File
@@ -17,7 +17,7 @@ describe('useDownloadProgressStore', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
useDownloadProgressStore.setState({ progressMap: {} });
useDownloadProgressStore.setState({ progressMap: {}, moveProgressMap: {} });
clearDownloadControlIntents();
});
@@ -43,7 +43,7 @@ describe('useDownloadProgressStore', () => {
const first = initDownloadListener();
const second = initDownloadListener();
expect(ipc.listenEvent).toHaveBeenCalledTimes(3);
expect(ipc.listenEvent).toHaveBeenCalledTimes(4);
const releaseFirst = await first;
const releaseSecond = await second;
@@ -51,7 +51,7 @@ describe('useDownloadProgressStore', () => {
expect(unlisten).not.toHaveBeenCalled();
releaseSecond();
expect(unlisten).toHaveBeenCalledTimes(3);
expect(unlisten).toHaveBeenCalledTimes(4);
});
it('ignores late progress and opposite terminal events from an older lifecycle', async () => {
+46 -5
View File
@@ -16,6 +16,7 @@ export { useDownloadProgressStore } from './downloadProgressStore';
let unlistenProgress: UnlistenFn | null = null;
let unlistenState: UnlistenFn | null = null;
let unlistenMoveProgress: UnlistenFn | null = null;
let unlistenTray: UnlistenFn | null = null;
let listenerSetup: Promise<void> | null = null;
let listenerConsumers = 0;
@@ -25,6 +26,8 @@ const disposeDownloadListeners = () => {
unlistenProgress = null;
unlistenState?.();
unlistenState = null;
unlistenMoveProgress?.();
unlistenMoveProgress = null;
unlistenTray?.();
unlistenTray = null;
listenerSetup = null;
@@ -71,6 +74,20 @@ const startDownloadListeners = async () => {
if (payload.total_is_estimate !== null && payload.total_is_estimate !== undefined) {
updates.totalIsEstimate = payload.total_is_estimate;
}
if (current.isTorrent) {
if (payload.uploaded_bytes !== null
&& payload.uploaded_bytes !== undefined
&& Number.isSafeInteger(payload.uploaded_bytes)
&& payload.uploaded_bytes >= 0) {
updates.torrentUploadedBytes = payload.uploaded_bytes;
}
if (payload.torrent_seeded_seconds !== null
&& payload.torrent_seeded_seconds !== undefined
&& Number.isSafeInteger(payload.torrent_seeded_seconds)
&& payload.torrent_seeded_seconds >= 0) {
updates.torrentSeededSeconds = payload.torrent_seeded_seconds;
}
}
const observedDownloadedBytes = Math.max(
current.downloadedBytes ?? 0,
payload.downloaded_bytes ?? 0
@@ -103,6 +120,9 @@ const startDownloadListeners = async () => {
return;
}
const status = payload.status as DownloadStatus;
if (status !== 'moving') {
useDownloadProgressStore.getState().clearMoveProgress(payload.id);
}
// resume_download queues the row before the backend can emit its new
// active state. Paused events already emitted by the old lifecycle may
@@ -120,7 +140,7 @@ const startDownloadListeners = async () => {
// applied while the transition is in flight.
return;
}
if (status === 'downloading' || status === 'processing' ||
if (status === 'downloading' || status === 'processing' || status === 'moving' ||
status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' ||
status === 'completed' || status === 'failed') {
clearDownloadControlIntent(payload.id, 'resume');
@@ -136,13 +156,14 @@ const startDownloadListeners = async () => {
// before asking the backend to resume, so an active event arriving while
// the row is still paused cannot represent a new lifecycle.
if ((current.status === 'completed' || current.status === 'failed') &&
status !== current.status) {
status !== current.status && status !== 'moving') {
return;
}
if (current.status === 'paused' &&
status !== 'paused' &&
status !== 'completed' &&
status !== 'failed') {
status !== 'failed' &&
status !== 'moving') {
return;
}
if (current.status === 'seeding' &&
@@ -150,7 +171,8 @@ const startDownloadListeners = async () => {
status !== 'waitingToSeed' &&
status !== 'paused' &&
status !== 'completed' &&
status !== 'failed') {
status !== 'failed' &&
status !== 'moving') {
return;
}
@@ -158,8 +180,14 @@ const startDownloadListeners = async () => {
if (['queued', 'retrying', 'completed', 'failed', 'paused', 'waitingToSeed'].includes(status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
const moveRestoreStatus = status === 'moving'
? current.status === 'paused' || current.status === 'completed' || current.status === 'failed'
? current.status
: current.torrentMoveRestoreStatus
: undefined;
const updates: Partial<DownloadItem> = {
status,
torrentMoveRestoreStatus: moveRestoreStatus,
...(progress ? {
fraction: progress.fraction,
...(progress.downloaded_bytes != null
@@ -221,6 +249,17 @@ const startDownloadListeners = async () => {
mainStore.unregisterBackendIds([payload.id]);
}
}),
listen('torrent-move-progress', (event) => {
const payload = event.payload;
const current = useDownloadStore.getState().downloads.find(d => d.id === payload.id);
if (!current || current.status !== 'moving') {
useDownloadProgressStore.getState().clearMoveProgress(payload.id);
return;
}
if (Number.isFinite(payload.fraction) && payload.fraction >= 0 && payload.fraction <= 1) {
useDownloadProgressStore.getState().setMoveProgress(payload.id, payload.fraction);
}
}),
listen('tray-action', (event) => {
const mainStore = useDownloadStore.getState();
if (event.payload === 'pause-all') {
@@ -241,13 +280,15 @@ const startDownloadListeners = async () => {
throw failedRegistration.reason;
}
const [progress, state, tray] = registrations as [
const [progress, state, moveProgress, tray] = registrations as [
PromiseFulfilledResult<UnlistenFn>,
PromiseFulfilledResult<UnlistenFn>,
PromiseFulfilledResult<UnlistenFn>,
PromiseFulfilledResult<UnlistenFn>,
];
unlistenProgress = progress.value;
unlistenState = state.value;
unlistenMoveProgress = moveProgress.value;
unlistenTray = tray.value;
};
+18
View File
@@ -938,6 +938,24 @@ describe('useDownloadStore', () => {
expect(normalized.torrentEncryptionPolicy).toBeUndefined();
});
it('recovers an interrupted Torrent move without discarding the native destination marker', () => {
const normalized = normalizePersistedDownloadProgress({
id: 'interrupted-torrent-move',
url: 'magnet:?xt=urn:btih:bad',
fileName: 'payload',
status: 'moving',
category: 'Other',
dateAdded: '',
destination: '/downloads/new',
torrentMoveDestination: '/downloads/new',
torrentMoveRestoreStatus: 'paused'
});
expect(normalized.status).toBe('paused');
expect(normalized.torrentMoveDestination).toBe('/downloads/new');
expect(normalized.torrentMoveRestoreStatus).toBe('paused');
});
it('normalizes proxy settings for download dispatch', async () => {
expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080');
expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000');
+61 -4
View File
@@ -352,7 +352,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
torrent_upload_limit: item.torrentUploadLimit || undefined,
torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
torrent_check_integrity: item.torrentCheckIntegrity,
torrent_check_integrity: item.torrentCheckIntegrity || item.torrentRelocationCheckPending,
torrent_trackers: item.torrentTrackers || undefined,
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
torrent_tracker_connect_timeout: item.torrentTrackerConnectTimeout,
@@ -636,6 +636,18 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
rawSeedRemaining >= 0
? rawSeedRemaining
: undefined;
const rawUploadedBytes = download.torrentUploadedBytes as unknown;
const normalizedUploadedBytes = typeof rawUploadedBytes === 'number'
&& Number.isSafeInteger(rawUploadedBytes)
&& rawUploadedBytes >= 0
? rawUploadedBytes
: rawUploadedBytes === undefined ? undefined : 0;
const rawSeededSeconds = download.torrentSeededSeconds as unknown;
const normalizedSeededSeconds = typeof rawSeededSeconds === 'number'
&& Number.isSafeInteger(rawSeededSeconds)
&& rawSeededSeconds >= 0
? rawSeededSeconds
: rawSeededSeconds === undefined ? undefined : 0;
const rawMaxPeers = download.torrentMaxPeers as unknown;
const normalizedMaxPeers = typeof rawMaxPeers === 'number' &&
Number.isInteger(rawMaxPeers) &&
@@ -654,6 +666,17 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
(seed as { uri: string }).uri.length <= 2048
).slice(0, 256)
: undefined;
const rawNativeWebSeeds = download.torrentWebSeedsNative as unknown;
const normalizedNativeWebSeeds = Array.isArray(rawNativeWebSeeds)
? rawNativeWebSeeds.filter((seed): seed is { fileIndex: number; uri: string } =>
!!seed && typeof seed === 'object' &&
typeof (seed as { fileIndex?: unknown }).fileIndex === 'number' &&
Number.isInteger((seed as { fileIndex: number }).fileIndex) &&
(seed as { fileIndex: number }).fileIndex >= 0 &&
typeof (seed as { uri?: unknown }).uri === 'string' &&
(seed as { uri: string }).uri.length <= 2048
).slice(0, 256)
: undefined;
const rawPeerSpeedLimit = download.torrentPeerSpeedLimit as unknown;
const normalizedPeerSpeedLimit = typeof rawPeerSpeedLimit === 'string'
? normalizeSpeedLimitForBackend(rawPeerSpeedLimit) || undefined
@@ -697,6 +720,24 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
const normalizedFileAllocation = normalizeTorrentFileAllocation(rawFileAllocation);
const rawVerifyOnly = download.torrentVerifyOnly as unknown;
const normalizedVerifyOnly = rawVerifyOnly === true ? true : undefined;
const rawRelocationCheckPending = download.torrentRelocationCheckPending as unknown;
const normalizedRelocationCheckPending = rawRelocationCheckPending === true ? true : undefined;
const rawMoveDestination = download.torrentMoveDestination as unknown;
const normalizedMoveDestination = typeof rawMoveDestination === 'string'
&& rawMoveDestination.trim()
? rawMoveDestination
: undefined;
const rawMoveRestoreStatus = download.torrentMoveRestoreStatus as unknown;
const normalizedMoveRestoreStatus: DownloadStatus | undefined = download.status === 'moving' && (
rawMoveRestoreStatus === 'paused'
|| rawMoveRestoreStatus === 'completed'
|| rawMoveRestoreStatus === 'failed'
)
? rawMoveRestoreStatus as DownloadStatus
: undefined;
const recoveredMoveStatus = download.status === 'moving'
? normalizedMoveRestoreStatus || 'failed'
: download.status;
const rawVerifyRestoreStatus = download.torrentVerifyRestoreStatus as unknown;
const normalizedVerifyRestoreStatus = normalizedVerifyOnly === true
&& typeof rawVerifyRestoreStatus === 'string'
@@ -704,7 +745,10 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
? rawVerifyRestoreStatus
: undefined;
const normalizedOptions = rawSeedRemaining !== normalizedSeedRemaining ||
rawUploadedBytes !== normalizedUploadedBytes ||
rawSeededSeconds !== normalizedSeededSeconds ||
rawWebSeeds !== normalizedWebSeeds ||
rawNativeWebSeeds !== normalizedNativeWebSeeds ||
rawMaxPeers !== normalizedMaxPeers ||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
rawCheckIntegrity !== normalizedCheckIntegrity ||
@@ -719,11 +763,19 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
rawEncryptionPolicy !== normalizedEncryptionPolicy ||
rawFileAllocation !== normalizedFileAllocation ||
rawVerifyOnly !== normalizedVerifyOnly ||
rawRelocationCheckPending !== normalizedRelocationCheckPending ||
rawMoveDestination !== normalizedMoveDestination ||
rawMoveRestoreStatus !== normalizedMoveRestoreStatus ||
recoveredMoveStatus !== download.status ||
rawVerifyRestoreStatus !== normalizedVerifyRestoreStatus
? {
? {
...download,
status: recoveredMoveStatus,
torrentSeedRemaining: normalizedSeedRemaining,
torrentUploadedBytes: normalizedUploadedBytes,
torrentSeededSeconds: normalizedSeededSeconds,
torrentWebSeeds: normalizedWebSeeds,
torrentWebSeedsNative: normalizedNativeWebSeeds,
torrentMaxPeers: normalizedMaxPeers,
torrentPeerSpeedLimit: normalizedPeerSpeedLimit,
torrentCheckIntegrity: normalizedCheckIntegrity,
@@ -738,9 +790,14 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
torrentEncryptionPolicy: normalizedEncryptionPolicy,
torrentFileAllocation: normalizedFileAllocation,
torrentVerifyOnly: normalizedVerifyOnly,
torrentRelocationCheckPending: normalizedRelocationCheckPending,
torrentMoveDestination: normalizedMoveDestination,
torrentMoveRestoreStatus: normalizedMoveRestoreStatus,
torrentVerifyRestoreStatus: normalizedVerifyRestoreStatus
}
: download;
: recoveredMoveStatus !== download.status
? { ...download, status: recoveredMoveStatus, torrentMoveRestoreStatus: normalizedMoveRestoreStatus }
: download;
return hasStaleTemporaryMediaEstimate(normalizedOptions)
? {
@@ -2300,7 +2357,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
torrent_upload_limit: item.torrentUploadLimit || undefined,
torrent_max_peers: item.torrentMaxPeers,
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
torrent_check_integrity: item.torrentCheckIntegrity,
torrent_check_integrity: item.torrentCheckIntegrity || item.torrentRelocationCheckPending,
torrent_trackers: item.torrentTrackers || undefined,
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
torrent_tracker_connect_timeout: item.torrentTrackerConnectTimeout,
+2
View File
@@ -5,6 +5,7 @@ import {
} from './downloads';
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
import type { TorrentFile } from '../bindings/TorrentFile';
import type { TorrentWebSeedDraft } from './downloads';
import i18n from '../i18n';
import { localePluralVariant } from '../i18n/locales';
@@ -67,6 +68,7 @@ export interface AddDownloadDraftRow {
torrentCheckIntegrity?: boolean;
torrentTrackers?: string;
torrentExcludeTrackers?: string;
torrentWebSeedRows?: TorrentWebSeedDraft[];
}
/**
+1 -1
View File
@@ -67,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 === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying';
status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying' || status === 'moving';
export const isIdentityLocked = (status: DownloadStatus): boolean =>
isTransferLocked(status) || status === 'completed';
+25
View File
@@ -34,6 +34,30 @@ export const formatDownloadBytes = (bytes: number): string => {
return `${formatDownloadBytesInUnit(bytes, unitIndex)} ${BYTE_UNITS[unitIndex]}`;
};
export const formatTorrentDuration = (seconds: number, locale: string): string => {
if (!Number.isFinite(seconds) || seconds < 0) return '—';
const rounded = Math.round(seconds);
const unit = (value: number, name: 'hour' | 'minute' | 'second') =>
new Intl.NumberFormat(locale, { style: 'unit', unit: name, unitDisplay: 'short' }).format(value);
if (rounded >= 3600) {
return `${unit(Math.floor(rounded / 3600), 'hour')} ${unit(Math.floor((rounded % 3600) / 60), 'minute')}`;
}
if (rounded >= 60) {
return `${unit(Math.floor(rounded / 60), 'minute')} ${unit(rounded % 60, 'second')}`;
}
return unit(rounded, 'second');
};
export const formatTorrentRatio = (uploadedBytes: number, denominatorBytes: number, locale: string): string => {
if (!Number.isFinite(uploadedBytes) || uploadedBytes < 0 || !Number.isFinite(denominatorBytes) || denominatorBytes <= 0) {
return '—';
}
return new Intl.NumberFormat(locale, {
maximumFractionDigits: 2,
minimumFractionDigits: 2
}).format(uploadedBytes / denominatorBytes);
};
export const formatDownloadTotal = (display: DownloadSizeDisplay): string =>
display.total && display.unit
? `${display.totalIsEstimate ? '~' : ''}${display.total} ${display.unit}`
@@ -72,6 +96,7 @@ export const downloadProgressColorClass = (status: string): string => {
case 'failed':
return 'download-status-failed';
case 'processing':
case 'moving':
return 'download-status-processing';
case 'verifying':
return 'download-status-processing';
+3 -2
View File
@@ -23,7 +23,8 @@ const isFreshDownloadStatus = (status: DownloadItem['status']): boolean =>
status === 'seeding' ||
status === 'processing' ||
status === 'verifying' ||
status === 'retrying';
status === 'retrying' ||
status === 'moving';
const hasPositiveProgress = (download: DownloadItem): boolean =>
typeof download.fraction === 'number' &&
@@ -81,7 +82,7 @@ export const summarizeDownloads = (
for (const download of downloads) {
const state = effectiveByteState(download, progressMap[download.id]);
if (isTransferActiveStatus(download.status)) activeCount += 1;
if (isTransferActiveStatus(download.status) || download.status === 'moving') activeCount += 1;
if (state.downloadedBytes === undefined) {
downloadedKnown = false;
} else {
+50
View File
@@ -11,6 +11,10 @@ import {
normalizeTorrentEncryptionPolicy,
normalizeTorrentMaxOpenFiles,
normalizeTorrentPrioritizePiece,
normalizeTorrentWebSeedDrafts,
parseTorrentPreviewPriority,
serializeTorrentPreviewPriority,
torrentWebSeedDraftsFromSeeds,
normalizeTorrentTrackerInterval,
normalizeTorrentTrackerTimeout,
redactDownloadForPersistence,
@@ -104,6 +108,52 @@ describe('Torrent piece priority validation', () => {
});
});
describe('Torrent preview controls', () => {
it('hydrates legacy head and tail syntax into independent controls', () => {
expect(parseTorrentPreviewPriority('tail=64k, HEAD')).toEqual({ head: '1M', tail: '64K' });
});
it('serializes enabled controls with native-compatible defaults', () => {
expect(serializeTorrentPreviewPriority(true, '', true, '2m')).toBe('head=1M,tail=2M');
expect(serializeTorrentPreviewPriority(false, '1M', false, '1M')).toBeNull();
});
});
describe('Torrent web-seed row normalization', () => {
const files = [{ index: 1 }, { index: 2 }];
it('round-trips rows and removes exact duplicates', () => {
const rows = torrentWebSeedDraftsFromSeeds([
{ fileIndex: 1, uri: 'https://mirror.example/a' },
{ fileIndex: 1, uri: 'https://mirror.example/a' }
]);
expect(normalizeTorrentWebSeedDrafts(rows, files)).toEqual([
{ fileIndex: 1, uri: 'https://mirror.example/a' }
]);
});
it('uses the only file for a fixed single-file selector', () => {
expect(normalizeTorrentWebSeedDrafts([{ fileIndex: null, uri: 'https://mirror.example/a' }], [{ index: 1 }])).toEqual([
{ fileIndex: 1, uri: 'https://mirror.example/a' }
]);
});
it('rejects unsafe, incomplete, and out-of-range rows', () => {
expect(normalizeTorrentWebSeedDrafts([{ fileIndex: 1, uri: 'ftp://mirror.example/a' }], files)).toBeNull();
expect(normalizeTorrentWebSeedDrafts([{ fileIndex: null, uri: 'https://mirror.example/a' }], files)).toBeNull();
expect(normalizeTorrentWebSeedDrafts([{ fileIndex: 9, uri: 'https://mirror.example/a' }], files)).toBeNull();
expect(normalizeTorrentWebSeedDrafts([{ fileIndex: 1, uri: 'https://user:pass@mirror.example/a' }], files)).toBeNull();
expect(normalizeTorrentWebSeedDrafts([{ fileIndex: 1, uri: `https://mirror.example/${'é'.repeat(1100)}` }], files)).toBeNull();
});
it('bounds the number of rows before they reach the native boundary', () => {
expect(normalizeTorrentWebSeedDrafts(
Array.from({ length: 65 }, (_, index) => ({ fileIndex: 1, uri: `https://mirror.example/${index}` })),
files
)).toBeNull();
});
});
describe('Torrent encryption policy validation', () => {
it('accepts only the canonical policy states', () => {
expect(normalizeTorrentEncryptionPolicy('disabled')).toBe('disabled');
+83
View File
@@ -1,6 +1,7 @@
import type { DownloadCategory } from '../bindings/DownloadCategory';
import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { DownloadItem } from '../bindings/DownloadItem';
import type { TorrentWebSeed } from '../bindings/TorrentWebSeed';
export type { DownloadCategory } from '../bindings/DownloadCategory';
import { invokeCommand as invoke } from '../ipc';
@@ -34,6 +35,7 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'seeding',
'waitingToSeed',
'retrying',
'moving',
]);
export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
@@ -214,6 +216,8 @@ const MAX_TORRENT_TRACKERS = 64;
const MAX_TORRENT_TRACKER_BYTES = 16 * 1024;
export const MAX_TORRENT_STOP_TIMEOUT = 7 * 24 * 60 * 60;
const MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB = 1024;
const MAX_TORRENT_WEB_SEEDS = 64;
const MAX_TORRENT_WEB_SEED_URI_BYTES = 2048;
const normalizeTorrentPiecePrioritySize = (value: string): string | null => {
const match = value.trim().match(/^(\d+)\s*([km])$/i);
@@ -267,6 +271,85 @@ export const normalizeTorrentPrioritizePiece = (value?: string | null): string |
return [head, tail].filter((part): part is string => Boolean(part)).join(',') || null;
};
export type TorrentPreviewPriority = {
head: string;
tail: string;
};
export const parseTorrentPreviewPriority = (value?: string | null): TorrentPreviewPriority => {
const normalized = normalizeTorrentPrioritizePiece(value);
const result: TorrentPreviewPriority = { head: '', tail: '' };
for (const token of normalized?.split(',') ?? []) {
const [keyword, size] = token.split('=', 2);
result[keyword as 'head' | 'tail'] = size || '1M';
}
return result;
};
export const serializeTorrentPreviewPriority = (
headEnabled: boolean,
headSize: string,
tailEnabled: boolean,
tailSize: string
): string | null => normalizeTorrentPrioritizePiece([
headEnabled ? `head=${headSize.trim() || '1M'}` : '',
tailEnabled ? `tail=${tailSize.trim() || '1M'}` : ''
].filter(Boolean).join(','));
export type TorrentWebSeedDraft = {
fileIndex: number | null;
uri: string;
};
export const torrentWebSeedDraftsFromSeeds = (
seeds: readonly TorrentWebSeed[] | undefined
): TorrentWebSeedDraft[] => (seeds ?? []).map(seed => ({
fileIndex: seed.fileIndex,
uri: seed.uri
}));
type TorrentWebSeedFile = { index: number };
export const normalizeTorrentWebSeedDrafts = (
drafts: readonly TorrentWebSeedDraft[],
files: readonly TorrentWebSeedFile[]
): TorrentWebSeed[] | null => {
if (drafts.length > MAX_TORRENT_WEB_SEEDS) return null;
const fileIndices = new Set(files.map(file => file.index));
const normalized: TorrentWebSeed[] = [];
const seen = new Set<string>();
for (const draft of drafts) {
const fileIndex = files.length === 1 ? files[0].index : draft.fileIndex;
const uri = draft.uri.trim();
if (
fileIndex === null
|| !fileIndices.has(fileIndex)
|| !uri
|| new TextEncoder().encode(uri).length > MAX_TORRENT_WEB_SEED_URI_BYTES
) return null;
let parsed: URL;
try {
parsed = new URL(uri);
} catch {
return null;
}
if (
!['http:', 'https:'].includes(parsed.protocol)
|| !parsed.hostname
|| parsed.username
|| parsed.password
|| parsed.hash
|| /[\u0000-\u001f\u007f]/u.test(uri)
) return null;
const normalizedUri = parsed.toString();
const key = `${fileIndex}\u0000${normalizedUri}`;
if (seen.has(key)) continue;
seen.add(key);
normalized.push({ fileIndex, uri: normalizedUri });
}
return normalized;
};
/**
* Performs the same user-facing safety checks as the native tracker boundary.
* The Rust validator remains authoritative because persisted data can bypass