mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-24 17:56:28 +00:00
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:
@@ -14,6 +14,7 @@
|
|||||||
"log:default",
|
"log:default",
|
||||||
"notification:default",
|
"notification:default",
|
||||||
"notification:allow-is-permission-granted",
|
"notification:allow-is-permission-granted",
|
||||||
"clipboard-manager:allow-read-text"
|
"clipboard-manager:allow-read-text",
|
||||||
|
"clipboard-manager:allow-write-text"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ pub enum DownloadStatus {
|
|||||||
/// Aria2 is verifying already-present Torrent data before transfer or
|
/// Aria2 is verifying already-present Torrent data before transfer or
|
||||||
/// after an explicit integrity check.
|
/// after an explicit integrity check.
|
||||||
Verifying,
|
Verifying,
|
||||||
|
/// Firelink is moving owned Torrent data between managed destinations.
|
||||||
|
Moving,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DownloadStatus {
|
impl DownloadStatus {
|
||||||
@@ -100,6 +102,7 @@ impl DownloadStatus {
|
|||||||
Self::Queued => "queued",
|
Self::Queued => "queued",
|
||||||
Self::Retrying => "retrying",
|
Self::Retrying => "retrying",
|
||||||
Self::Verifying => "verifying",
|
Self::Verifying => "verifying",
|
||||||
|
Self::Moving => "moving",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,10 +222,28 @@ pub struct DownloadItem {
|
|||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
pub torrent_seed_remaining: Option<f64>,
|
pub torrent_seed_remaining: Option<f64>,
|
||||||
#[serde(default)]
|
#[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)]
|
#[ts(optional)]
|
||||||
pub torrent_web_seeds: Option<Vec<TorrentWebSeed>>,
|
pub torrent_web_seeds: Option<Vec<TorrentWebSeed>>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
|
pub torrent_web_seeds_native: Option<Vec<TorrentWebSeed>>,
|
||||||
|
#[serde(default)]
|
||||||
|
#[ts(optional)]
|
||||||
pub torrent_upload_limit: Option<String>,
|
pub torrent_upload_limit: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[ts(optional)]
|
#[ts(optional)]
|
||||||
@@ -372,6 +393,38 @@ pub struct TorrentDetails {
|
|||||||
pub web_seeds: Vec<String>,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize, TS, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
|||||||
+1644
-6
File diff suppressed because it is too large
Load Diff
+615
-4
@@ -10,7 +10,7 @@ use std::future::Future;
|
|||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex as StdMutex};
|
use std::sync::{Arc, Mutex as StdMutex};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{AppHandle, Manager};
|
use tauri::{AppHandle, Manager};
|
||||||
use tokio::sync::{Mutex, Notify, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore};
|
use tokio::sync::{Mutex, Notify, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore};
|
||||||
use ts_rs::TS;
|
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_ID_PREFIX_BYTES: usize = 20;
|
||||||
pub const MAX_TORRENT_PEER_AGENT_LENGTH: usize = 128;
|
pub const MAX_TORRENT_PEER_AGENT_LENGTH: usize = 128;
|
||||||
pub const MAX_TORRENT_PIECES_FOR_PROGRESS: u64 = 10_000_000;
|
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_SEEDS: usize = 256;
|
||||||
pub const MAX_TORRENT_WEB_SEED_URI_LENGTH: usize = 2_048;
|
pub const MAX_TORRENT_WEB_SEED_URI_LENGTH: usize = 2_048;
|
||||||
pub const MIN_TORRENT_LISTEN_PORT: u16 = 1024;
|
pub const MIN_TORRENT_LISTEN_PORT: u16 = 1024;
|
||||||
@@ -479,6 +483,97 @@ pub struct Aria2GidMapping {
|
|||||||
pub epoch: u64,
|
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
|
/// Owns one per-download control lock and removes its idle map entry when the
|
||||||
/// last operation for that download finishes.
|
/// last operation for that download finishes.
|
||||||
pub struct Aria2ControlGuard {
|
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.
|
/// alive, but release the download semaphore while they own a seed slot.
|
||||||
seed_capacity: StdMutex<SeedCapacityState>,
|
seed_capacity: StdMutex<SeedCapacityState>,
|
||||||
seed_budgets: StdMutex<HashMap<String, SeedBudget>>,
|
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).
|
/// aria2 gid -> download id map (shared with the WS poller).
|
||||||
pub aria2_gids: Arc<std::sync::RwLock<HashMap<String, Aria2GidMapping>>>,
|
pub aria2_gids: Arc<std::sync::RwLock<HashMap<String, Aria2GidMapping>>>,
|
||||||
@@ -882,6 +982,8 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
..SeedCapacityState::default()
|
..SeedCapacityState::default()
|
||||||
}),
|
}),
|
||||||
seed_budgets: StdMutex::new(HashMap::new()),
|
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())),
|
aria2_gids: Arc::new(std::sync::RwLock::new(HashMap::new())),
|
||||||
pending_completion: Arc::new(Mutex::new(HashMap::new())),
|
pending_completion: Arc::new(Mutex::new(HashMap::new())),
|
||||||
aria2_payloads: 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)
|
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> {
|
pub fn app_handle(&self) -> AppHandle<R> {
|
||||||
self.app_handle.clone()
|
self.app_handle.clone()
|
||||||
}
|
}
|
||||||
@@ -1603,6 +1785,17 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
.is_some_and(torrent_seeding_requested)
|
.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 {
|
pub async fn aria2_is_torrent(&self, id: &str) -> bool {
|
||||||
self.aria2_payloads
|
self.aria2_payloads
|
||||||
.lock()
|
.lock()
|
||||||
@@ -1740,7 +1933,16 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
&self,
|
&self,
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
|
) -> 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
|
let payload = self
|
||||||
.aria2_payloads
|
.aria2_payloads
|
||||||
.lock()
|
.lock()
|
||||||
@@ -1776,7 +1978,17 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
id: &str,
|
id: &str,
|
||||||
seeds: &[crate::ipc::TorrentWebSeed],
|
seeds: &[crate::ipc::TorrentWebSeed],
|
||||||
) -> Result<Vec<crate::ipc::TorrentWebSeed>, String> {
|
) -> 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
|
let payload = self
|
||||||
.aria2_payloads
|
.aria2_payloads
|
||||||
.lock()
|
.lock()
|
||||||
@@ -1847,7 +2059,23 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
),
|
),
|
||||||
String,
|
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
|
let old_payload = self
|
||||||
.aria2_payloads
|
.aria2_payloads
|
||||||
.lock()
|
.lock()
|
||||||
@@ -2349,6 +2577,87 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
Ok(diagnostics)
|
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
|
/// Return a lifecycle-fenced, metadata-derived projection of Aria2's
|
||||||
/// per-file progress. The daemon's absolute paths and URI lists are never
|
/// per-file progress. The daemon's absolute paths and URI lists are never
|
||||||
/// copied across the boundary.
|
/// 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);
|
.is_some_and(|payload| payload.is_torrent && payload.torrent_remove_unselected_file);
|
||||||
match outcome {
|
match outcome {
|
||||||
PendingOutcome::Complete => {
|
PendingOutcome::Complete => {
|
||||||
|
self.forget_torrent_telemetry(id).await;
|
||||||
self.clear_aria2_retry_state(id).await;
|
self.clear_aria2_retry_state(id).await;
|
||||||
self.forget_aria2_gid(id).await;
|
self.forget_aria2_gid(id).await;
|
||||||
if torrent_removal_requested {
|
if torrent_removal_requested {
|
||||||
@@ -3535,6 +3845,7 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
self.emit_state(id, restored_status);
|
self.emit_state(id, restored_status);
|
||||||
}
|
}
|
||||||
PendingOutcome::Error(error) => {
|
PendingOutcome::Error(error) => {
|
||||||
|
self.forget_torrent_telemetry(id).await;
|
||||||
if !verification_only && error.to_ascii_lowercase().contains("checksum") {
|
if !verification_only && error.to_ascii_lowercase().contains("checksum") {
|
||||||
log::warn!("Checksum error detected for {}, cleaning up assets", id);
|
log::warn!("Checksum error detected for {}, cleaning up assets", id);
|
||||||
if let Ok(paths) =
|
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(
|
pub(crate) fn parse_torrent_peer_diagnostics(
|
||||||
result: serde_json::Value,
|
result: serde_json::Value,
|
||||||
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
|
) -> 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(0)), 0);
|
||||||
assert_eq!(automatic_retry_limit(Some(2)), 2);
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ import type { DownloadCategory } from "./DownloadCategory";
|
|||||||
import type { DownloadStatus } from "./DownloadStatus";
|
import type { DownloadStatus } from "./DownloadStatus";
|
||||||
import type { TorrentWebSeed } from "./TorrentWebSeed";
|
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,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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,3 +1,3 @@
|
|||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// 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>, };
|
||||||
@@ -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, };
|
||||||
@@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat
|
|||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
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 { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
|
||||||
import {
|
import {
|
||||||
expandTilde,
|
expandTilde,
|
||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
type MediaSelection
|
type MediaSelection
|
||||||
} from '../utils/addDownloadMetadata';
|
} from '../utils/addDownloadMetadata';
|
||||||
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
||||||
|
import { TorrentWebSeedEditor } from './TorrentWebSeedEditor';
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
@@ -241,7 +242,17 @@ export const AddDownloadsModal = () => {
|
|||||||
const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState('');
|
const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState('');
|
||||||
const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0');
|
const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0');
|
||||||
const [torrentStopTimeout, setTorrentStopTimeout] = 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 [freeSpace, setFreeSpace] = useState('Unknown');
|
||||||
const freeSpaceRequestRef = useRef(0);
|
const freeSpaceRequestRef = useRef(0);
|
||||||
|
|
||||||
@@ -389,6 +400,11 @@ export const AddDownloadsModal = () => {
|
|||||||
setTorrentTrackerTimeout('');
|
setTorrentTrackerTimeout('');
|
||||||
setTorrentTrackerInterval('0');
|
setTorrentTrackerInterval('0');
|
||||||
setTorrentStopTimeout('0');
|
setTorrentStopTimeout('0');
|
||||||
|
setTorrentFileAllocation('prealloc');
|
||||||
|
setTorrentPreviewHeadEnabled(false);
|
||||||
|
setTorrentPreviewHeadSize('1M');
|
||||||
|
setTorrentPreviewTailEnabled(false);
|
||||||
|
setTorrentPreviewTailSize('1M');
|
||||||
setUseAuth(false);
|
setUseAuth(false);
|
||||||
setUsername('');
|
setUsername('');
|
||||||
setPassword('');
|
setPassword('');
|
||||||
@@ -1005,10 +1021,18 @@ export const AddDownloadsModal = () => {
|
|||||||
addToast({ message: t($ => $.addDownloads.torrentTrackerIntervalInvalid), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.addDownloads.torrentTrackerIntervalInvalid), variant: 'error', isActionable: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasSelectedTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) {
|
if (hasSelectedTorrent && (torrentPreviewHeadEnabled || torrentPreviewTailEnabled) && !torrentPreviewPriority) {
|
||||||
addToast({ message: t($ => $.addDownloads.torrentPrioritizePieceInvalid), variant: 'error', isActionable: true });
|
addToast({ message: t($ => $.addDownloads.torrentPrioritizePieceInvalid), variant: 'error', isActionable: true });
|
||||||
return;
|
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 (
|
if (
|
||||||
hasSelectedTorrent
|
hasSelectedTorrent
|
||||||
&& torrentStopTimeout.trim()
|
&& torrentStopTimeout.trim()
|
||||||
@@ -1529,7 +1553,11 @@ export const AddDownloadsModal = () => {
|
|||||||
? Number(torrentTrackerInterval)
|
? Number(torrentTrackerInterval)
|
||||||
: undefined,
|
: undefined,
|
||||||
torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : 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),
|
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined),
|
||||||
sizeBytes: item.sizeBytes
|
sizeBytes: item.sizeBytes
|
||||||
}, action);
|
}, action);
|
||||||
@@ -2112,6 +2140,24 @@ export const AddDownloadsModal = () => {
|
|||||||
</section>
|
</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 && (
|
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isTorrent && (
|
||||||
<section className="add-download-section relative overflow-hidden p-4">
|
<section className="add-download-section relative overflow-hidden p-4">
|
||||||
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
<div className="add-download-section-title flex items-center gap-2 mb-3">
|
||||||
@@ -2195,6 +2241,64 @@ export const AddDownloadsModal = () => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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">
|
<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">
|
<label htmlFor="torrent-encryption-policy" className="text-text-muted">
|
||||||
{t($ => $.addDownloads.torrentEncryptionPolicy)}
|
{t($ => $.addDownloads.torrentEncryptionPolicy)}
|
||||||
@@ -2382,23 +2486,6 @@ export const AddDownloadsModal = () => {
|
|||||||
{t($ => $.addDownloads.torrentStopTimeoutHint)}
|
{t($ => $.addDownloads.torrentStopTimeoutHint)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useSettingsStore } from '../store/useSettingsStore';
|
|||||||
import { formatDateTime } from '../utils/dateTime';
|
import { formatDateTime } from '../utils/dateTime';
|
||||||
import {
|
import {
|
||||||
downloadProgressColorClass,
|
downloadProgressColorClass,
|
||||||
|
formatTorrentDuration,
|
||||||
formatDownloadTotal,
|
formatDownloadTotal,
|
||||||
resolveDownloadSizeDisplay
|
resolveDownloadSizeDisplay
|
||||||
} from '../utils/downloadProgress';
|
} from '../utils/downloadProgress';
|
||||||
@@ -72,6 +73,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const calendarPreference = useSettingsStore(state => state.calendarPreference);
|
const calendarPreference = useSettingsStore(state => state.calendarPreference);
|
||||||
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
const liveProgress = useDownloadProgressStore(state => state.progressMap[download.id]);
|
||||||
|
const moveProgress = useDownloadProgressStore(state => state.moveProgressMap[download.id]);
|
||||||
const rowRef = React.useRef<HTMLDivElement>(null);
|
const rowRef = React.useRef<HTMLDivElement>(null);
|
||||||
const [isRowHovered, setIsRowHovered] = React.useState(false);
|
const [isRowHovered, setIsRowHovered] = React.useState(false);
|
||||||
const [isRowKeyboardFocused, setIsRowKeyboardFocused] = React.useState(false);
|
const [isRowKeyboardFocused, setIsRowKeyboardFocused] = React.useState(false);
|
||||||
@@ -178,7 +180,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
};
|
};
|
||||||
}, [isActionVisible, updateActionPosition]);
|
}, [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
|
? liveProgress?.fraction ?? download.fraction ?? 0
|
||||||
: download.fraction ?? 0;
|
: download.fraction ?? 0;
|
||||||
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
|
const displayPercent = `${(displayFraction * 100).toFixed(0)}%`;
|
||||||
@@ -190,7 +194,9 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
? t($ => $.downloads.values.processing)
|
? t($ => $.downloads.values.processing)
|
||||||
: '-';
|
: '-';
|
||||||
const displayEta = download.status === 'seeding'
|
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'
|
: download.status === 'downloading' || download.status === 'verifying'
|
||||||
? liveProgress?.eta ?? download.eta
|
? liveProgress?.eta ?? download.eta
|
||||||
: download.status === 'processing'
|
: download.status === 'processing'
|
||||||
@@ -298,6 +304,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
download.status === 'seeding' ? 'seeding' :
|
download.status === 'seeding' ? 'seeding' :
|
||||||
download.status === 'processing' ? 'processing' :
|
download.status === 'processing' ? 'processing' :
|
||||||
download.status === 'verifying' ? 'processing' :
|
download.status === 'verifying' ? 'processing' :
|
||||||
|
download.status === 'moving' ? 'processing' :
|
||||||
download.status === 'queued' || download.status === 'staged' ? 'queued' :
|
download.status === 'queued' || download.status === 'staged' ? 'queued' :
|
||||||
download.status === 'retrying' ? 'retrying' : ''
|
download.status === 'retrying' ? 'retrying' : ''
|
||||||
}`}
|
}`}
|
||||||
@@ -322,6 +329,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
download.status === 'failed' ? 'download-status-failed' :
|
download.status === 'failed' ? 'download-status-failed' :
|
||||||
download.status === 'processing' ? 'download-status-processing' :
|
download.status === 'processing' ? 'download-status-processing' :
|
||||||
download.status === 'verifying' ? 'download-status-processing' :
|
download.status === 'verifying' ? 'download-status-processing' :
|
||||||
|
download.status === 'moving' ? 'download-status-processing' :
|
||||||
download.status === 'downloading' ? 'download-status-downloading' :
|
download.status === 'downloading' ? 'download-status-downloading' :
|
||||||
download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' :
|
download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' :
|
||||||
download.status === 'retrying' ? 'download-status-retrying' : ''
|
download.status === 'retrying' ? 'download-status-retrying' : ''
|
||||||
@@ -334,7 +342,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
|||||||
{downloadStatusLabel} #{queueIndex + 1}
|
{downloadStatusLabel} #{queueIndex + 1}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : download.status === 'downloading' || download.status === 'verifying' ? (
|
) : download.status === 'downloading' || download.status === 'verifying' || download.status === 'moving' ? (
|
||||||
displayPercent
|
displayPercent
|
||||||
) : download.status === 'seeding' ? (
|
) : download.status === 'seeding' ? (
|
||||||
displayPercent
|
displayPercent
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
|
import { isActiveDownloadStatus, isTransferActiveStatus } from '../utils/downloads';
|
||||||
import { summarizeDownloads, type DownloadSummary } from '../utils/downloadSummary';
|
import { summarizeDownloads, type DownloadSummary } from '../utils/downloadSummary';
|
||||||
import { readClipboardDownloadUrls } from '../utils/clipboard';
|
import { readClipboardDownloadUrls } from '../utils/clipboard';
|
||||||
|
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
sortDownloads,
|
sortDownloads,
|
||||||
@@ -2552,6 +2553,23 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
|||||||
{t($ => $.downloadTable.copyAddress)}
|
{t($ => $.downloadTable.copyAddress)}
|
||||||
</button>
|
</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' && (
|
{contextItem.status === 'completed' && (
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgres
|
|||||||
import type { TorrentPieceProgressSnapshot } from '../bindings/TorrentPieceProgressSnapshot';
|
import type { TorrentPieceProgressSnapshot } from '../bindings/TorrentPieceProgressSnapshot';
|
||||||
import type { TorrentFileSelectionSnapshot } from '../bindings/TorrentFileSelectionSnapshot';
|
import type { TorrentFileSelectionSnapshot } from '../bindings/TorrentFileSelectionSnapshot';
|
||||||
import type { TorrentDetails } from '../bindings/TorrentDetails';
|
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 { invokeCommand as invoke } from '../ipc';
|
||||||
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
|
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 { resolveCategoryDestination } from '../utils/downloadLocations';
|
||||||
import {
|
import {
|
||||||
getPauseResumeAction,
|
getPauseResumeAction,
|
||||||
@@ -22,12 +23,15 @@ import {
|
|||||||
downloadProgressColorClass,
|
downloadProgressColorClass,
|
||||||
formatDownloadBytes,
|
formatDownloadBytes,
|
||||||
formatDownloadTotal,
|
formatDownloadTotal,
|
||||||
|
formatTorrentDuration,
|
||||||
|
formatTorrentRatio,
|
||||||
resolveDownloadSizeDisplay
|
resolveDownloadSizeDisplay
|
||||||
} from '../utils/downloadProgress';
|
} 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 { useTranslation } from 'react-i18next';
|
||||||
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
|
import { formatDateTime, type CalendarPreference } from '../utils/dateTime';
|
||||||
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus';
|
||||||
|
import { TorrentWebSeedEditor } from './TorrentWebSeedEditor';
|
||||||
|
|
||||||
type LoginMode = 'matching' | 'custom' | 'none';
|
type LoginMode = 'matching' | 'custom' | 'none';
|
||||||
|
|
||||||
@@ -50,6 +54,9 @@ const isPeerDiagnosticsStatus = (status: string): boolean =>
|
|||||||
const isTorrentFileProgressStatus = (status: string): boolean =>
|
const isTorrentFileProgressStatus = (status: string): boolean =>
|
||||||
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status);
|
['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 =>
|
const formatPeerSpeed = (bytesPerSecond: number): string =>
|
||||||
`${formatDownloadBytes(bytesPerSecond)}/s`;
|
`${formatDownloadBytes(bytesPerSecond)}/s`;
|
||||||
|
|
||||||
@@ -78,6 +85,9 @@ export const PropertiesModal = () => {
|
|||||||
? state.progressMap[selectedPropertiesDownloadId]
|
? state.progressMap[selectedPropertiesDownloadId]
|
||||||
: undefined
|
: undefined
|
||||||
));
|
));
|
||||||
|
const moveProgress = useDownloadProgressStore(state =>
|
||||||
|
selectedPropertiesDownloadId ? state.moveProgressMap[selectedPropertiesDownloadId] : undefined
|
||||||
|
);
|
||||||
|
|
||||||
const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore();
|
const { baseDownloadFolder, perServerConnections, calendarPreference } = useSettingsStore();
|
||||||
|
|
||||||
@@ -104,7 +114,10 @@ export const PropertiesModal = () => {
|
|||||||
const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState('');
|
const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState('');
|
||||||
const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0');
|
const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0');
|
||||||
const [torrentStopTimeout, setTorrentStopTimeout] = 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 [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState<TorrentPeerDiagnostics | null>(null);
|
||||||
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
|
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
|
||||||
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
|
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
|
||||||
@@ -119,10 +132,15 @@ export const PropertiesModal = () => {
|
|||||||
const [torrentPieceProgress, setTorrentPieceProgress] = useState<TorrentPieceProgressSnapshot | null>(null);
|
const [torrentPieceProgress, setTorrentPieceProgress] = useState<TorrentPieceProgressSnapshot | null>(null);
|
||||||
const [torrentPieceProgressError, setTorrentPieceProgressError] = useState(false);
|
const [torrentPieceProgressError, setTorrentPieceProgressError] = useState(false);
|
||||||
const [isTorrentPieceProgressPending, setIsTorrentPieceProgressPending] = 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 [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
|
||||||
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
|
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
|
||||||
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
|
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
|
||||||
const [torrentWebSeedsText, setTorrentWebSeedsText] = useState('');
|
const [torrentWebSeedRows, setTorrentWebSeedRows] = useState<TorrentWebSeedDraft[]>([]);
|
||||||
const [torrentWebSeedsError, setTorrentWebSeedsError] = useState(false);
|
const [torrentWebSeedsError, setTorrentWebSeedsError] = useState(false);
|
||||||
const [isTorrentWebSeedsPending, setIsTorrentWebSeedsPending] = useState(false);
|
const [isTorrentWebSeedsPending, setIsTorrentWebSeedsPending] = useState(false);
|
||||||
|
|
||||||
@@ -149,6 +167,7 @@ export const PropertiesModal = () => {
|
|||||||
const torrentFileSelectionRequestRef = useRef(0);
|
const torrentFileSelectionRequestRef = useRef(0);
|
||||||
const torrentDetailsRequestRef = useRef(0);
|
const torrentDetailsRequestRef = useRef(0);
|
||||||
const torrentPieceProgressRequestRef = useRef(0);
|
const torrentPieceProgressRequestRef = useRef(0);
|
||||||
|
const torrentAvailabilityRequestRef = useRef(0);
|
||||||
const torrentWebSeedsRequestRef = useRef(0);
|
const torrentWebSeedsRequestRef = useRef(0);
|
||||||
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
|
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
|
||||||
|
|
||||||
@@ -175,9 +194,16 @@ export const PropertiesModal = () => {
|
|||||||
setTorrentPieceProgress(null);
|
setTorrentPieceProgress(null);
|
||||||
setTorrentPieceProgressError(false);
|
setTorrentPieceProgressError(false);
|
||||||
setIsTorrentPieceProgressPending(false);
|
setIsTorrentPieceProgressPending(false);
|
||||||
|
torrentAvailabilityRequestRef.current += 1;
|
||||||
|
setTorrentAvailability(null);
|
||||||
|
setTorrentAvailabilityError(false);
|
||||||
|
setIsTorrentAvailabilityPending(false);
|
||||||
|
setTorrentShareMessage('');
|
||||||
|
setIsTorrentMovePending(false);
|
||||||
torrentWebSeedsRequestRef.current += 1;
|
torrentWebSeedsRequestRef.current += 1;
|
||||||
setTorrentWebSeedsError(false);
|
setTorrentWebSeedsError(false);
|
||||||
setIsTorrentWebSeedsPending(false);
|
setIsTorrentWebSeedsPending(false);
|
||||||
|
setTorrentWebSeedRows([]);
|
||||||
}, [selectedPropertiesDownloadId]);
|
}, [selectedPropertiesDownloadId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -251,10 +277,12 @@ export const PropertiesModal = () => {
|
|||||||
setTorrentTrackerTimeout(activeItem.torrentTrackerTimeout === undefined ? '' : String(activeItem.torrentTrackerTimeout));
|
setTorrentTrackerTimeout(activeItem.torrentTrackerTimeout === undefined ? '' : String(activeItem.torrentTrackerTimeout));
|
||||||
setTorrentTrackerInterval(activeItem.torrentTrackerInterval === undefined ? '0' : String(activeItem.torrentTrackerInterval));
|
setTorrentTrackerInterval(activeItem.torrentTrackerInterval === undefined ? '0' : String(activeItem.torrentTrackerInterval));
|
||||||
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
|
setTorrentStopTimeout(activeItem.torrentStopTimeout === undefined ? '0' : String(activeItem.torrentStopTimeout));
|
||||||
setTorrentPrioritizePiece(activeItem.torrentPrioritizePiece || '');
|
const previewPriority = parseTorrentPreviewPriority(activeItem.torrentPrioritizePiece);
|
||||||
setTorrentWebSeedsText((activeItem.torrentWebSeeds || [])
|
setTorrentPreviewHeadEnabled(Boolean(previewPriority.head));
|
||||||
.map(seed => `${seed.fileIndex}|${seed.uri}`)
|
setTorrentPreviewHeadSize(previewPriority.head || '1M');
|
||||||
.join('\n'));
|
setTorrentPreviewTailEnabled(Boolean(previewPriority.tail));
|
||||||
|
setTorrentPreviewTailSize(previewPriority.tail || '1M');
|
||||||
|
setTorrentWebSeedRows(torrentWebSeedDraftsFromSeeds(activeItem.torrentWebSeeds));
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
} else {
|
} else {
|
||||||
setSelectedPropertiesDownloadId(null);
|
setSelectedPropertiesDownloadId(null);
|
||||||
@@ -389,7 +417,7 @@ export const PropertiesModal = () => {
|
|||||||
requestId === torrentWebSeedsRequestRef.current
|
requestId === torrentWebSeedsRequestRef.current
|
||||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||||
) {
|
) {
|
||||||
setTorrentWebSeedsText(seeds.map(seed => `${seed.fileIndex}|${seed.uri}`).join('\n'));
|
setTorrentWebSeedRows(torrentWebSeedDraftsFromSeeds(seeds));
|
||||||
useDownloadStore.getState().updateDownload(propertiesDownloadId, { torrentWebSeeds: 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 () => {
|
const handleTorrentWebSeedsSave = async () => {
|
||||||
if (!item?.isTorrent || isTorrentWebSeedsPending) return;
|
if (!item?.isTorrent || isTorrentWebSeedsPending) return;
|
||||||
const seeds: TorrentWebSeed[] = [];
|
const files = torrentWebSeedFiles.map(file => ({ index: file.index }));
|
||||||
for (const line of torrentWebSeedsText.split(/\r?\n/)) {
|
const seeds = normalizeTorrentWebSeedDrafts(torrentWebSeedRows, files);
|
||||||
const trimmed = line.trim();
|
if (!seeds) {
|
||||||
if (!trimmed) continue;
|
setTorrentWebSeedsError(true);
|
||||||
const separator = trimmed.indexOf('|');
|
return;
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
setIsTorrentWebSeedsPending(true);
|
setIsTorrentWebSeedsPending(true);
|
||||||
setTorrentWebSeedsError(false);
|
setTorrentWebSeedsError(false);
|
||||||
try {
|
try {
|
||||||
const normalized = await invoke('set_torrent_web_seeds', { id: item.id, seeds });
|
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 });
|
useDownloadStore.getState().updateDownload(item.id, { torrentWebSeeds: normalized });
|
||||||
} catch {
|
} catch {
|
||||||
setTorrentWebSeedsError(true);
|
setTorrentWebSeedsError(true);
|
||||||
@@ -711,7 +839,13 @@ export const PropertiesModal = () => {
|
|||||||
setErrorMessage(t($ => $.properties.torrentTrackerIntervalInvalid));
|
setErrorMessage(t($ => $.properties.torrentTrackerIntervalInvalid));
|
||||||
return;
|
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));
|
setErrorMessage(t($ => $.properties.torrentPrioritizePieceInvalid));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -786,7 +920,7 @@ export const PropertiesModal = () => {
|
|||||||
? Number(torrentTrackerInterval)
|
? Number(torrentTrackerInterval)
|
||||||
: undefined,
|
: undefined,
|
||||||
torrentStopTimeout: normalizedStopTimeout,
|
torrentStopTimeout: normalizedStopTimeout,
|
||||||
torrentPrioritizePiece: normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined,
|
torrentPrioritizePiece: torrentPrioritizePiece || undefined,
|
||||||
torrentFileIndices: torrentFileSelection
|
torrentFileIndices: torrentFileSelection
|
||||||
? (allTorrentFilesSelected ? undefined : selectedTorrentIndices)
|
? (allTorrentFilesSelected ? undefined : selectedTorrentIndices)
|
||||||
: item.torrentFileIndices,
|
: item.torrentFileIndices,
|
||||||
@@ -1018,6 +1152,20 @@ export const PropertiesModal = () => {
|
|||||||
const value = item.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
|
const value = item.status === 'completed' ? formatDownloadTotal(sizeDisplay) : sizeDisplay.fallback;
|
||||||
return value === 'Unknown' ? t($ => $.addDownloads.unknown) : value;
|
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 statusLabel = t($ => $.downloads.status[item.status]);
|
||||||
const pauseResumeAction = getPauseResumeAction(item.status);
|
const pauseResumeAction = getPauseResumeAction(item.status);
|
||||||
const pauseResumeLabel = pauseResumeAction === 'pause'
|
const pauseResumeLabel = pauseResumeAction === 'pause'
|
||||||
@@ -1040,7 +1188,7 @@ export const PropertiesModal = () => {
|
|||||||
let StatusIcon = Info;
|
let StatusIcon = Info;
|
||||||
if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; }
|
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 === '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 === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; }
|
||||||
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
|
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
|
||||||
|
|
||||||
@@ -1113,6 +1261,19 @@ export const PropertiesModal = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
|
||||||
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
|
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
|
||||||
@@ -1347,6 +1508,54 @@ export const PropertiesModal = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</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">{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="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="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="text-xs font-semibold text-text-primary">
|
<div className="text-xs font-semibold text-text-primary">
|
||||||
@@ -1610,21 +1819,45 @@ export const PropertiesModal = () => {
|
|||||||
{t($ => $.properties.torrentStopTimeoutHint)}
|
{t($ => $.properties.torrentStopTimeoutHint)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="text-xs text-text-muted text-right" htmlFor="torrent-prioritize-piece-properties">
|
<div className="col-span-2 space-y-2">
|
||||||
{t($ => $.properties.torrentPrioritizePiece)}
|
<span className="block text-xs text-text-muted">{t($ => $.properties.torrentPrioritizePiece)}</span>
|
||||||
</label>
|
<label className="flex items-center gap-2 text-xs text-text-primary">
|
||||||
<div>
|
<input
|
||||||
<input
|
type="checkbox"
|
||||||
id="torrent-prioritize-piece-properties"
|
checked={torrentPreviewHeadEnabled}
|
||||||
type="text"
|
onChange={event => setTorrentPreviewHeadEnabled(event.target.checked)}
|
||||||
value={torrentPrioritizePiece}
|
disabled={transferLocked}
|
||||||
onChange={event => setTorrentPrioritizePiece(event.currentTarget.value)}
|
className="accent-blue-500"
|
||||||
placeholder="head=1M,tail=1M"
|
/>
|
||||||
disabled={transferLocked}
|
{t($ => $.properties.torrentPrioritizePieceHead)}
|
||||||
aria-describedby="torrent-prioritize-piece-properties-hint"
|
<input
|
||||||
className="app-control w-full px-2.5 py-1.5 text-xs font-mono disabled:opacity-50"
|
type="text"
|
||||||
/>
|
value={torrentPreviewHeadSize}
|
||||||
<p id="torrent-prioritize-piece-properties-hint" className="mt-1 text-[11px] text-text-muted">
|
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)}
|
{t($ => $.properties.torrentPrioritizePieceHint)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
|
||||||
{t($ => $.properties.torrentDetails)}
|
{t($ => $.properties.torrentDetails)}
|
||||||
</h3>
|
</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 && (
|
{isTorrentDetailsPending && (
|
||||||
<p className="text-xs text-text-muted">{t($ => $.properties.torrentDetailsLoading)}</p>
|
<p className="text-xs text-text-muted">{t($ => $.properties.torrentDetailsLoading)}</p>
|
||||||
)}
|
)}
|
||||||
@@ -1972,13 +2228,12 @@ export const PropertiesModal = () => {
|
|||||||
{t($ => $.properties.torrentWebSeeds)}
|
{t($ => $.properties.torrentWebSeeds)}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-text-muted mb-2">{t($ => $.properties.torrentWebSeedsHint)}</p>
|
<p className="text-xs text-text-muted mb-2">{t($ => $.properties.torrentWebSeedsHint)}</p>
|
||||||
<textarea
|
<TorrentWebSeedEditor
|
||||||
value={torrentWebSeedsText}
|
files={torrentWebSeedFiles}
|
||||||
onChange={event => setTorrentWebSeedsText(event.target.value)}
|
rows={torrentWebSeedRows}
|
||||||
placeholder={t($ => $.properties.torrentWebSeedsPlaceholder)}
|
onChange={setTorrentWebSeedRows}
|
||||||
disabled={isTorrentWebSeedsPending}
|
disabled={isTorrentWebSeedsPending}
|
||||||
aria-label={t($ => $.properties.torrentWebSeeds)}
|
idPrefix="properties-torrent-web-seed"
|
||||||
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"
|
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between mt-2 gap-2">
|
<div className="flex items-center justify-between mt-2 gap-2">
|
||||||
<span className="text-xs text-red-500">
|
<span className="text-xs text-red-500">
|
||||||
@@ -1987,7 +2242,7 @@ export const PropertiesModal = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void handleTorrentWebSeedsSave()}
|
onClick={() => void handleTorrentWebSeedsSave()}
|
||||||
disabled={isTorrentWebSeedsPending}
|
disabled={isTorrentWebSeedsPending || torrentWebSeedsMetadataPending}
|
||||||
className="app-button px-3 text-xs"
|
className="app-button px-3 text-xs"
|
||||||
>
|
>
|
||||||
{isTorrentWebSeedsPending ? t($ => $.properties.torrentWebSeedsLoading) : t($ => $.properties.torrentWebSeedsApply)}
|
{isTorrentWebSeedsPending ? t($ => $.properties.torrentWebSeedsLoading) : t($ => $.properties.torrentWebSeedsApply)}
|
||||||
|
|||||||
@@ -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
@@ -96,6 +96,7 @@ const common = {
|
|||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
failed: 'Failed',
|
failed: 'Failed',
|
||||||
retrying: 'Retrying',
|
retrying: 'Retrying',
|
||||||
|
moving: 'Moving data',
|
||||||
},
|
},
|
||||||
values: {
|
values: {
|
||||||
processing: 'Processing...',
|
processing: 'Processing...',
|
||||||
@@ -288,11 +289,16 @@ const common = {
|
|||||||
torrentPieceProgressSummary: '{{completed}} of {{total}} pieces complete · {{size}} each',
|
torrentPieceProgressSummary: '{{completed}} of {{total}} pieces complete · {{size}} each',
|
||||||
torrentPieceProgressMap: 'Torrent piece completion map',
|
torrentPieceProgressMap: 'Torrent piece completion map',
|
||||||
torrentWebSeeds: 'Torrent web seeds',
|
torrentWebSeeds: 'Torrent web seeds',
|
||||||
torrentWebSeedsHint: 'One line per seed in the form file index|HTTP(S) URI. Firelink expands multi-file paths natively.',
|
torrentWebSeedsHint: 'Add one HTTP(S) base URI per Torrent file. Firelink expands multi-file paths natively.',
|
||||||
torrentWebSeedsPlaceholder: '0|https://mirror.example/torrent/',
|
|
||||||
torrentWebSeedsApply: 'Apply web seeds',
|
torrentWebSeedsApply: 'Apply web seeds',
|
||||||
torrentWebSeedsLoading: 'Applying…',
|
torrentWebSeedsLoading: 'Applying…',
|
||||||
torrentWebSeedsFailed: 'Could not validate or apply the Torrent web seeds.',
|
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',
|
torrentPeerCount: '{{total}} peers · {{seeders}} seeders',
|
||||||
torrentPeerDownload: 'Download',
|
torrentPeerDownload: 'Download',
|
||||||
torrentPeerUpload: 'Upload',
|
torrentPeerUpload: 'Upload',
|
||||||
@@ -300,12 +306,22 @@ const common = {
|
|||||||
torrentPeerAmChoking: 'Firelink choking',
|
torrentPeerAmChoking: 'Firelink choking',
|
||||||
torrentPeerChoking: 'Peer choking',
|
torrentPeerChoking: 'Peer choking',
|
||||||
torrentPeerShowing: 'Showing {{shown}} of {{total}} peers.',
|
torrentPeerShowing: 'Showing {{shown}} of {{total}} peers.',
|
||||||
|
torrentStatistics: 'Torrent statistics',
|
||||||
|
torrentUploaded: 'Uploaded',
|
||||||
|
torrentRatio: 'Ratio',
|
||||||
|
torrentSeededDuration: 'Seeded',
|
||||||
|
torrentConnectedPeers: 'Peers',
|
||||||
|
torrentSeeders: 'Seeders',
|
||||||
|
torrentUploadSpeed: 'Upload speed',
|
||||||
seconds: 'seconds',
|
seconds: 'seconds',
|
||||||
torrentStopTimeout: 'Stop stalled Torrent after',
|
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.',
|
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',
|
torrentStopTimeoutInvalid: 'Torrent stall timeout must be a whole number from 0 to 604800 seconds',
|
||||||
torrentPrioritizePiece: 'Prioritize Torrent pieces',
|
torrentPrioritizePiece: 'Prioritize first/last pieces for preview',
|
||||||
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.',
|
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',
|
torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M',
|
||||||
torrentEncryptionPolicy: 'Torrent encryption policy',
|
torrentEncryptionPolicy: 'Torrent encryption policy',
|
||||||
torrentFileAllocation: 'Torrent file allocation',
|
torrentFileAllocation: 'Torrent file allocation',
|
||||||
@@ -313,6 +329,28 @@ const common = {
|
|||||||
torrentFileAllocationNone: 'Allocate as needed',
|
torrentFileAllocationNone: 'Allocate as needed',
|
||||||
torrentFileAllocationHint: 'Preallocation reserves the selected files before transfer. Allocation as needed avoids that upfront disk reservation.',
|
torrentFileAllocationHint: 'Preallocation reserves the selected files before transfer. Allocation as needed avoids that upfront disk reservation.',
|
||||||
torrentDetails: 'Torrent details',
|
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…',
|
torrentDetailsLoading: 'Loading Torrent details…',
|
||||||
torrentDetailsUnavailable: 'Torrent details are not available.',
|
torrentDetailsUnavailable: 'Torrent details are not available.',
|
||||||
torrentDetailsDisplayName: 'Display name',
|
torrentDetailsDisplayName: 'Display name',
|
||||||
@@ -513,12 +551,14 @@ const common = {
|
|||||||
moveOneFailed: 'Could not move download to queue',
|
moveOneFailed: 'Could not move download to queue',
|
||||||
copyAddressesFailed: 'Could not copy addresses',
|
copyAddressesFailed: 'Could not copy addresses',
|
||||||
copyAddressFailed: 'Could not copy address',
|
copyAddressFailed: 'Could not copy address',
|
||||||
|
copyMagnetFailed: 'Could not copy the magnet link',
|
||||||
copyPathFailed: 'Could not copy file path',
|
copyPathFailed: 'Could not copy file path',
|
||||||
missingFileName: 'File name is missing',
|
missingFileName: 'File name is missing',
|
||||||
redownloadFailed: 'Redownload failed',
|
redownloadFailed: 'Redownload failed',
|
||||||
startResume: 'Start/Resume',
|
startResume: 'Start/Resume',
|
||||||
addToQueue: 'Add to Queue',
|
addToQueue: 'Add to Queue',
|
||||||
copyAddress: 'Copy Address',
|
copyAddress: 'Copy Address',
|
||||||
|
copyMagnet: 'Copy magnet link',
|
||||||
remove: 'Remove',
|
remove: 'Remove',
|
||||||
open: 'Open',
|
open: 'Open',
|
||||||
showInFolder: 'Show in Folder',
|
showInFolder: 'Show in Folder',
|
||||||
@@ -603,8 +643,11 @@ const common = {
|
|||||||
torrentStopTimeout: 'Stop stalled Torrent after',
|
torrentStopTimeout: 'Stop stalled Torrent after',
|
||||||
torrentStopTimeoutHint: 'Aria2 stops this Torrent after this many consecutive seconds at 0 B/s. 0 disables the policy.',
|
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',
|
torrentStopTimeoutInvalid: 'Torrent stall timeout must be a whole number from 0 to 604800 seconds',
|
||||||
torrentPrioritizePiece: 'Prioritize Torrent pieces',
|
torrentPrioritizePiece: 'Prioritize first/last pieces for preview',
|
||||||
torrentPrioritizePieceHint: 'Saved with this Torrent and applied on its next start or retry. Use head, tail, or both with optional K or M sizes.',
|
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',
|
torrentPrioritizePieceInvalid: 'Torrent piece priority must use head and/or tail with optional K or M sizes between 1K and 1024M',
|
||||||
torrentEncryptionPolicy: 'Torrent encryption policy',
|
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.',
|
torrentEncryptionPolicyHint: 'Saved with this Torrent and applied on its next start or retry. The selected policy keeps Aria2 encryption settings consistent.',
|
||||||
|
|||||||
+49
-6
@@ -96,6 +96,7 @@ const fa = {
|
|||||||
completed: 'تکمیلشده',
|
completed: 'تکمیلشده',
|
||||||
failed: 'ناموفق',
|
failed: 'ناموفق',
|
||||||
retrying: 'در حال تلاش مجدد',
|
retrying: 'در حال تلاش مجدد',
|
||||||
|
moving: 'در حال جابهجایی داده',
|
||||||
},
|
},
|
||||||
values: {
|
values: {
|
||||||
processing: 'در حال پردازش…',
|
processing: 'در حال پردازش…',
|
||||||
@@ -288,11 +289,16 @@ const fa = {
|
|||||||
torrentPieceProgressSummary: '{{completed}} از {{total}} قطعه کامل شده · هرکدام {{size}}',
|
torrentPieceProgressSummary: '{{completed}} از {{total}} قطعه کامل شده · هرکدام {{size}}',
|
||||||
torrentPieceProgressMap: 'نقشه تکمیل قطعههای تورنت',
|
torrentPieceProgressMap: 'نقشه تکمیل قطعههای تورنت',
|
||||||
torrentWebSeeds: 'وبسیدهای تورنت',
|
torrentWebSeeds: 'وبسیدهای تورنت',
|
||||||
torrentWebSeedsHint: 'هر خط بهشکل شماره فایل|نشانی HTTP(S). مسیر فایلهای چندفایلی را Firelink در بخش native میسازد.',
|
torrentWebSeedsHint: 'برای هر فایل تورنت یک نشانی پایهٔ HTTP(S) اضافه کنید. Firelink مسیر فایلهای چندفایلی را خودش میسازد.',
|
||||||
torrentWebSeedsPlaceholder: '۰|https://mirror.example/torrent/',
|
|
||||||
torrentWebSeedsApply: 'اعمال وبسیدها',
|
torrentWebSeedsApply: 'اعمال وبسیدها',
|
||||||
torrentWebSeedsLoading: 'در حال اعمال…',
|
torrentWebSeedsLoading: 'در حال اعمال…',
|
||||||
torrentWebSeedsFailed: 'اعتبارسنجی یا اعمال وبسیدهای تورنت انجام نشد.',
|
torrentWebSeedsFailed: 'اعتبارسنجی یا اعمال وبسیدهای تورنت انجام نشد.',
|
||||||
|
torrentWebSeedsEmpty: 'وبسیدی تنظیم نشده است.',
|
||||||
|
torrentWebSeedsFile: 'فایل',
|
||||||
|
torrentWebSeedsUri: 'نشانی پایهٔ HTTP(S)',
|
||||||
|
torrentWebSeedsAdd: 'افزودن وبسید',
|
||||||
|
torrentWebSeedsRemove: 'حذف وبسید',
|
||||||
|
torrentWebSeedsInvalid: 'هر ردیف وبسید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.',
|
||||||
torrentPeerCount: '{{total}} همتا · {{seeders}} سید',
|
torrentPeerCount: '{{total}} همتا · {{seeders}} سید',
|
||||||
torrentPeerDownload: 'دریافت',
|
torrentPeerDownload: 'دریافت',
|
||||||
torrentPeerUpload: 'آپلود',
|
torrentPeerUpload: 'آپلود',
|
||||||
@@ -300,12 +306,22 @@ const fa = {
|
|||||||
torrentPeerAmChoking: 'محدودسازی از طرف Firelink',
|
torrentPeerAmChoking: 'محدودسازی از طرف Firelink',
|
||||||
torrentPeerChoking: 'محدودسازی از طرف همتا',
|
torrentPeerChoking: 'محدودسازی از طرف همتا',
|
||||||
torrentPeerShowing: 'نمایش {{shown}} همتا از {{total}} همتا.',
|
torrentPeerShowing: 'نمایش {{shown}} همتا از {{total}} همتا.',
|
||||||
|
torrentStatistics: 'آمار تورنت',
|
||||||
|
torrentUploaded: 'آپلودشده',
|
||||||
|
torrentRatio: 'نسبت',
|
||||||
|
torrentSeededDuration: 'مدت سید',
|
||||||
|
torrentConnectedPeers: 'همتاها',
|
||||||
|
torrentSeeders: 'سیدها',
|
||||||
|
torrentUploadSpeed: 'سرعت آپلود',
|
||||||
seconds: 'ثانیه',
|
seconds: 'ثانیه',
|
||||||
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
|
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
|
||||||
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف میکند. ۰ این سیاست را غیرفعال میکند؛ تغییرات هنگام شروع یا تلاش مجدد اعمال میشوند.',
|
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف میکند. ۰ این سیاست را غیرفعال میکند؛ تغییرات هنگام شروع یا تلاش مجدد اعمال میشوند.',
|
||||||
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
|
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
|
||||||
torrentPrioritizePiece: 'اولویتبندی قطعههای تورنت',
|
torrentPrioritizePiece: 'اولویت دادن به قطعههای ابتدا/انتها برای پیشنمایش',
|
||||||
torrentPrioritizePieceHint: 'سیاست اختیاری پیشنمایش آریا۲: ابتدا، انتها یا هر دو؛ برای هرکدام میتوان اندازهای مثل 1M نوشت. تغییرات هنگام شروع یا تلاش مجدد اعمال میشوند.',
|
torrentPrioritizePieceHead: 'اولویت قطعههای ابتدا',
|
||||||
|
torrentPrioritizePieceTail: 'اولویت قطعههای انتها',
|
||||||
|
torrentPrioritizePieceSize: 'اندازهٔ بازهٔ پیشنمایش',
|
||||||
|
torrentPrioritizePieceHint: 'سیاست اختیاری پیشنمایش. هر بازهٔ فعال بهطور پیشفرض 1M است و هنگام شروع یا تلاش مجدد اعمال میشود.',
|
||||||
torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
|
torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
|
||||||
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
|
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
|
||||||
torrentFileAllocation: 'نحوهٔ تخصیص فایل تورنت',
|
torrentFileAllocation: 'نحوهٔ تخصیص فایل تورنت',
|
||||||
@@ -313,6 +329,28 @@ const fa = {
|
|||||||
torrentFileAllocationNone: 'تخصیص هنگام نیاز',
|
torrentFileAllocationNone: 'تخصیص هنگام نیاز',
|
||||||
torrentFileAllocationHint: 'تخصیص از پیش فضای فایلهای انتخابشده را قبل از انتقال رزرو میکند؛ تخصیص هنگام نیاز این رزرو اولیه را انجام نمیدهد.',
|
torrentFileAllocationHint: 'تخصیص از پیش فضای فایلهای انتخابشده را قبل از انتقال رزرو میکند؛ تخصیص هنگام نیاز این رزرو اولیه را انجام نمیدهد.',
|
||||||
torrentDetails: 'جزئیات تورنت',
|
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: 'در حال دریافت جزئیات تورنت…',
|
torrentDetailsLoading: 'در حال دریافت جزئیات تورنت…',
|
||||||
torrentDetailsUnavailable: 'جزئیات تورنت در دسترس نیست.',
|
torrentDetailsUnavailable: 'جزئیات تورنت در دسترس نیست.',
|
||||||
torrentDetailsDisplayName: 'نام نمایشی',
|
torrentDetailsDisplayName: 'نام نمایشی',
|
||||||
@@ -513,12 +551,14 @@ const fa = {
|
|||||||
moveOneFailed: 'انتقال دانلود به صف ناموفق بود',
|
moveOneFailed: 'انتقال دانلود به صف ناموفق بود',
|
||||||
copyAddressesFailed: 'آدرسها کپی نشدند',
|
copyAddressesFailed: 'آدرسها کپی نشدند',
|
||||||
copyAddressFailed: 'آدرس کپی نشد',
|
copyAddressFailed: 'آدرس کپی نشد',
|
||||||
|
copyMagnetFailed: 'پیوند مگنت کپی نشد',
|
||||||
copyPathFailed: 'مسیر فایل کپی نشد',
|
copyPathFailed: 'مسیر فایل کپی نشد',
|
||||||
missingFileName: 'نام فایل وجود ندارد',
|
missingFileName: 'نام فایل وجود ندارد',
|
||||||
redownloadFailed: 'دانلود مجدد ناموفق بود',
|
redownloadFailed: 'دانلود مجدد ناموفق بود',
|
||||||
startResume: 'شروع/ادامه',
|
startResume: 'شروع/ادامه',
|
||||||
addToQueue: 'افزودن به صف',
|
addToQueue: 'افزودن به صف',
|
||||||
copyAddress: 'کپی آدرس',
|
copyAddress: 'کپی آدرس',
|
||||||
|
copyMagnet: 'کپی پیوند مگنت',
|
||||||
remove: 'حذف',
|
remove: 'حذف',
|
||||||
open: 'باز کردن',
|
open: 'باز کردن',
|
||||||
showInFolder: 'نمایش در پوشه',
|
showInFolder: 'نمایش در پوشه',
|
||||||
@@ -603,8 +643,11 @@ const fa = {
|
|||||||
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
|
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
|
||||||
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف میکند. ۰ این سیاست را غیرفعال میکند.',
|
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف میکند. ۰ این سیاست را غیرفعال میکند.',
|
||||||
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
|
torrentStopTimeoutInvalid: 'مهلت توقف تورنت باید عددی صحیح بین ۰ و ۶۰۴۸۰۰ ثانیه باشد',
|
||||||
torrentPrioritizePiece: 'اولویتبندی قطعههای تورنت',
|
torrentPrioritizePiece: 'اولویت دادن به قطعههای ابتدا/انتها برای پیشنمایش',
|
||||||
torrentPrioritizePieceHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود. ابتدا، انتها یا هر دو را با اندازه اختیاری K یا M وارد کنید.',
|
torrentPrioritizePieceHead: 'اولویت قطعههای ابتدا',
|
||||||
|
torrentPrioritizePieceTail: 'اولویت قطعههای انتها',
|
||||||
|
torrentPrioritizePieceSize: 'اندازهٔ بازهٔ پیشنمایش',
|
||||||
|
torrentPrioritizePieceHint: 'قطعههای ابتدا، انتها یا هر دو را برای پیشنمایش انتخاب کنید. هر بازهٔ فعال بهطور پیشفرض 1M است و در شروع یا تلاش مجدد بعدی اعمال میشود.',
|
||||||
torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
|
torrentPrioritizePieceInvalid: 'اولویت قطعههای تورنت باید شامل ابتدا یا انتها، با اندازه اختیاری بین 1K و 1024M باشد',
|
||||||
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
|
torrentEncryptionPolicy: 'سیاست رمزنگاری تورنت',
|
||||||
torrentEncryptionPolicyHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود. سیاست انتخابی تنظیمات رمزنگاری آریا۲ را سازگار نگه میدارد.',
|
torrentEncryptionPolicyHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال میشود. سیاست انتخابی تنظیمات رمزنگاری آریا۲ را سازگار نگه میدارد.',
|
||||||
|
|||||||
+49
-6
@@ -96,6 +96,7 @@ const he = {
|
|||||||
completed: 'הושלם',
|
completed: 'הושלם',
|
||||||
failed: 'נכשל',
|
failed: 'נכשל',
|
||||||
retrying: 'ניסיון חוזר',
|
retrying: 'ניסיון חוזר',
|
||||||
|
moving: 'מעביר נתונים',
|
||||||
},
|
},
|
||||||
values: {
|
values: {
|
||||||
processing: 'מעבד…',
|
processing: 'מעבד…',
|
||||||
@@ -288,11 +289,16 @@ const he = {
|
|||||||
torrentPieceProgressSummary: '{{completed}} מתוך {{total}} חלקים הושלמו · {{size}} לכל חלק',
|
torrentPieceProgressSummary: '{{completed}} מתוך {{total}} חלקים הושלמו · {{size}} לכל חלק',
|
||||||
torrentPieceProgressMap: 'מפת השלמת חלקי הטורנט',
|
torrentPieceProgressMap: 'מפת השלמת חלקי הטורנט',
|
||||||
torrentWebSeeds: 'זריעות Web של טורנט',
|
torrentWebSeeds: 'זריעות Web של טורנט',
|
||||||
torrentWebSeedsHint: 'שורה אחת לכל זרע בפורמט file index|כתובת HTTP(S). Firelink מרחיב נתיבי קבצים מרובי-קבצים באופן מקורי.',
|
torrentWebSeedsHint: 'הוסף כתובת בסיס HTTP(S) אחת לכל קובץ טורנט. Firelink מרחיב נתיבים של טורנטים מרובי-קבצים.',
|
||||||
torrentWebSeedsPlaceholder: '0|https://מראה.example/torrent/',
|
|
||||||
torrentWebSeedsApply: 'החל זריעות Web',
|
torrentWebSeedsApply: 'החל זריעות Web',
|
||||||
torrentWebSeedsLoading: 'מיישם…',
|
torrentWebSeedsLoading: 'מיישם…',
|
||||||
torrentWebSeedsFailed: 'לא ניתן לאמת או להחיל את זריעות ה-Web של הטורנט.',
|
torrentWebSeedsFailed: 'לא ניתן לאמת או להחיל את זריעות ה-Web של הטורנט.',
|
||||||
|
torrentWebSeedsEmpty: 'לא הוגדרו זריעות Web.',
|
||||||
|
torrentWebSeedsFile: 'קובץ',
|
||||||
|
torrentWebSeedsUri: 'כתובת בסיס HTTP(S)',
|
||||||
|
torrentWebSeedsAdd: 'הוסף זריעת Web',
|
||||||
|
torrentWebSeedsRemove: 'הסר זריעת Web',
|
||||||
|
torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.',
|
||||||
torrentPeerCount: '{{total}} עמיתים · {{seeders}} משתפים',
|
torrentPeerCount: '{{total}} עמיתים · {{seeders}} משתפים',
|
||||||
torrentPeerDownload: 'הורדה',
|
torrentPeerDownload: 'הורדה',
|
||||||
torrentPeerUpload: 'העלאה',
|
torrentPeerUpload: 'העלאה',
|
||||||
@@ -300,12 +306,22 @@ const he = {
|
|||||||
torrentPeerAmChoking: 'Firelink מגביל',
|
torrentPeerAmChoking: 'Firelink מגביל',
|
||||||
torrentPeerChoking: 'העמית מגביל',
|
torrentPeerChoking: 'העמית מגביל',
|
||||||
torrentPeerShowing: 'מוצגים {{shown}} מתוך {{total}} עמיתים.',
|
torrentPeerShowing: 'מוצגים {{shown}} מתוך {{total}} עמיתים.',
|
||||||
|
torrentStatistics: 'סטטיסטיקות טורנט',
|
||||||
|
torrentUploaded: 'הועלה',
|
||||||
|
torrentRatio: 'יחס',
|
||||||
|
torrentSeededDuration: 'משך שיתוף',
|
||||||
|
torrentConnectedPeers: 'עמיתים',
|
||||||
|
torrentSeeders: 'משתפים',
|
||||||
|
torrentUploadSpeed: 'מהירות העלאה',
|
||||||
seconds: 'שניות',
|
seconds: 'שניות',
|
||||||
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
|
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
|
||||||
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות; השינוי חל כשהטורנט מתחיל או מנסה שוב.',
|
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות; השינוי חל כשהטורנט מתחיל או מנסה שוב.',
|
||||||
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
|
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
|
||||||
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
|
torrentPrioritizePiece: 'תעדוף החלקים הראשונים/האחרונים לתצוגה מקדימה',
|
||||||
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית של Aria2: התחלה, סוף או שניהם; לכל אחד אפשר לציין גודל כמו 1M. השינוי חל בהפעלה או בניסיון חוזר.',
|
torrentPrioritizePieceHead: 'תעדף חלקים ראשונים',
|
||||||
|
torrentPrioritizePieceTail: 'תעדף חלקים אחרונים',
|
||||||
|
torrentPrioritizePieceSize: 'גודל טווח התצוגה המקדימה',
|
||||||
|
torrentPrioritizePieceHint: 'מדיניות תצוגה מקדימה אופציונלית. כל טווח מופעל מתחיל ב־1M ומוחל כשהטורנט מופעל או מנסה שוב.',
|
||||||
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
|
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
|
||||||
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
|
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
|
||||||
torrentFileAllocation: 'הקצאת קובצי Torrent',
|
torrentFileAllocation: 'הקצאת קובצי Torrent',
|
||||||
@@ -313,6 +329,28 @@ const he = {
|
|||||||
torrentFileAllocationNone: 'הקצאה לפי הצורך',
|
torrentFileAllocationNone: 'הקצאה לפי הצורך',
|
||||||
torrentFileAllocationHint: 'הקצאה מראש שומרת מקום לקבצים לפני ההעברה; הקצאה לפי הצורך נמנעת מהשמירה הראשונית.',
|
torrentFileAllocationHint: 'הקצאה מראש שומרת מקום לקבצים לפני ההעברה; הקצאה לפי הצורך נמנעת מהשמירה הראשונית.',
|
||||||
torrentDetails: 'פרטי טורנט',
|
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: 'טוען פרטי טורנט…',
|
torrentDetailsLoading: 'טוען פרטי טורנט…',
|
||||||
torrentDetailsUnavailable: 'פרטי הטורנט אינם זמינים.',
|
torrentDetailsUnavailable: 'פרטי הטורנט אינם זמינים.',
|
||||||
torrentDetailsDisplayName: 'שם תצוגה',
|
torrentDetailsDisplayName: 'שם תצוגה',
|
||||||
@@ -513,12 +551,14 @@ const he = {
|
|||||||
moveOneFailed: 'לא ניתן להעביר הורדה לתור',
|
moveOneFailed: 'לא ניתן להעביר הורדה לתור',
|
||||||
copyAddressesFailed: 'לא ניתן להעתיק כתובות',
|
copyAddressesFailed: 'לא ניתן להעתיק כתובות',
|
||||||
copyAddressFailed: 'לא ניתן להעתיק כתובת',
|
copyAddressFailed: 'לא ניתן להעתיק כתובת',
|
||||||
|
copyMagnetFailed: 'לא ניתן להעתיק את קישור המגנט',
|
||||||
copyPathFailed: 'לא ניתן להעתיק נתיב קובץ',
|
copyPathFailed: 'לא ניתן להעתיק נתיב קובץ',
|
||||||
missingFileName: 'שם הקובץ חסר',
|
missingFileName: 'שם הקובץ חסר',
|
||||||
redownloadFailed: 'הורדה מחדש נכשלה',
|
redownloadFailed: 'הורדה מחדש נכשלה',
|
||||||
startResume: 'הפעלה/חידוש',
|
startResume: 'הפעלה/חידוש',
|
||||||
addToQueue: 'הוספה לתור',
|
addToQueue: 'הוספה לתור',
|
||||||
copyAddress: 'העתקת כתובת',
|
copyAddress: 'העתקת כתובת',
|
||||||
|
copyMagnet: 'העתקת קישור מגנט',
|
||||||
remove: 'הסרה',
|
remove: 'הסרה',
|
||||||
open: 'פתיחה',
|
open: 'פתיחה',
|
||||||
showInFolder: 'הצגה בתיקייה',
|
showInFolder: 'הצגה בתיקייה',
|
||||||
@@ -603,8 +643,11 @@ const he = {
|
|||||||
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
|
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
|
||||||
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות.',
|
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות.',
|
||||||
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
|
torrentStopTimeoutInvalid: 'זמן העצירה של טורנט תקוע חייב להיות מספר שלם בין 0 ל-604800 שניות',
|
||||||
torrentPrioritizePiece: 'תעדוף חלקי טורנט',
|
torrentPrioritizePiece: 'תעדוף החלקים הראשונים/האחרונים לתצוגה מקדימה',
|
||||||
torrentPrioritizePieceHint: 'נשמר עם הטורנט ומוחל בהפעלה או בניסיון חוזר. יש להזין התחלה, סוף או שניהם עם גודל K או M אופציונלי.',
|
torrentPrioritizePieceHead: 'תעדף חלקים ראשונים',
|
||||||
|
torrentPrioritizePieceTail: 'תעדף חלקים אחרונים',
|
||||||
|
torrentPrioritizePieceSize: 'גודל טווח התצוגה המקדימה',
|
||||||
|
torrentPrioritizePieceHint: 'בחר חלקים ראשונים, אחרונים או את שניהם לתצוגה מקדימה. כל טווח מופעל מתחיל ב־1M ומוחל בהפעלה או בניסיון חוזר הבא.',
|
||||||
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
|
torrentPrioritizePieceInvalid: 'תעדוף חלקי טורנט חייב לכלול התחלה ו/או סוף, עם גודל אופציונלי בין 1K ל-1024M',
|
||||||
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
|
torrentEncryptionPolicy: 'מדיניות הצפנת Torrent',
|
||||||
torrentEncryptionPolicyHint: 'נשמרת עם ה-Torrent ומוחלת בהפעלה או בניסיון חוזר. המדיניות שומרת על הגדרות ההצפנה של Aria2 עקביות.',
|
torrentEncryptionPolicyHint: 'נשמרת עם ה-Torrent ומוחלת בהפעלה או בניסיון חוזר. המדיניות שומרת על הגדרות ההצפנה של Aria2 עקביות.',
|
||||||
|
|||||||
+49
-6
@@ -96,6 +96,7 @@ const ru = {
|
|||||||
completed: 'Завершено',
|
completed: 'Завершено',
|
||||||
failed: 'Ошибка',
|
failed: 'Ошибка',
|
||||||
retrying: 'Повторная попытка',
|
retrying: 'Повторная попытка',
|
||||||
|
moving: 'Перемещение данных',
|
||||||
},
|
},
|
||||||
values: {
|
values: {
|
||||||
processing: 'Обработка…',
|
processing: 'Обработка…',
|
||||||
@@ -288,11 +289,16 @@ const ru = {
|
|||||||
torrentPieceProgressSummary: '{{completed}} из {{total}} частей завершено · по {{size}} на часть',
|
torrentPieceProgressSummary: '{{completed}} из {{total}} частей завершено · по {{size}} на часть',
|
||||||
torrentPieceProgressMap: 'Карта завершения частей торрента',
|
torrentPieceProgressMap: 'Карта завершения частей торрента',
|
||||||
torrentWebSeeds: 'Веб-сиды торрента',
|
torrentWebSeeds: 'Веб-сиды торрента',
|
||||||
torrentWebSeedsHint: 'Одна строка на сид в формате индекс файла|HTTP(S)-URI. Firelink сам расширяет пути многофайловых торрентов.',
|
torrentWebSeedsHint: 'Добавьте по одному базовому HTTP(S)-адресу для каждого файла торрента. Firelink сам расширит пути многофайлового торрента.',
|
||||||
torrentWebSeedsPlaceholder: '0|https://зеркало.example/torrent/',
|
|
||||||
torrentWebSeedsApply: 'Применить веб-сиды',
|
torrentWebSeedsApply: 'Применить веб-сиды',
|
||||||
torrentWebSeedsLoading: 'Применение…',
|
torrentWebSeedsLoading: 'Применение…',
|
||||||
torrentWebSeedsFailed: 'Не удалось проверить или применить веб-сиды торрента.',
|
torrentWebSeedsFailed: 'Не удалось проверить или применить веб-сиды торрента.',
|
||||||
|
torrentWebSeedsEmpty: 'Веб-сиды не настроены.',
|
||||||
|
torrentWebSeedsFile: 'Файл',
|
||||||
|
torrentWebSeedsUri: 'Базовый HTTP(S)-адрес',
|
||||||
|
torrentWebSeedsAdd: 'Добавить веб-сид',
|
||||||
|
torrentWebSeedsRemove: 'Удалить веб-сид',
|
||||||
|
torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.',
|
||||||
torrentPeerCount: '{{total}} пиров · {{seeders}} сидеров',
|
torrentPeerCount: '{{total}} пиров · {{seeders}} сидеров',
|
||||||
torrentPeerDownload: 'Загрузка',
|
torrentPeerDownload: 'Загрузка',
|
||||||
torrentPeerUpload: 'Отдача',
|
torrentPeerUpload: 'Отдача',
|
||||||
@@ -300,12 +306,22 @@ const ru = {
|
|||||||
torrentPeerAmChoking: 'Firelink ограничивает',
|
torrentPeerAmChoking: 'Firelink ограничивает',
|
||||||
torrentPeerChoking: 'Пир ограничивает',
|
torrentPeerChoking: 'Пир ограничивает',
|
||||||
torrentPeerShowing: 'Показано {{shown}} из {{total}} пиров.',
|
torrentPeerShowing: 'Показано {{shown}} из {{total}} пиров.',
|
||||||
|
torrentStatistics: 'Статистика торрента',
|
||||||
|
torrentUploaded: 'Отдано',
|
||||||
|
torrentRatio: 'Коэффициент',
|
||||||
|
torrentSeededDuration: 'Время раздачи',
|
||||||
|
torrentConnectedPeers: 'Пиры',
|
||||||
|
torrentSeeders: 'Сиды',
|
||||||
|
torrentUploadSpeed: 'Скорость отдачи',
|
||||||
seconds: 'секунд',
|
seconds: 'секунд',
|
||||||
torrentStopTimeout: 'Останавливать неактивный торрент через',
|
torrentStopTimeout: 'Останавливать неактивный торрент через',
|
||||||
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило; изменения применяются при запуске или повторной попытке.',
|
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило; изменения применяются при запуске или повторной попытке.',
|
||||||
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
|
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
|
||||||
torrentPrioritizePiece: 'Приоритет частей торрента',
|
torrentPrioritizePiece: 'Приоритет первых/последних частей для предпросмотра',
|
||||||
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра Aria2: начало, конец или оба варианта; для каждого можно указать размер, например 1M. Применяется при запуске или повторной попытке.',
|
torrentPrioritizePieceHead: 'Приоритет первых частей',
|
||||||
|
torrentPrioritizePieceTail: 'Приоритет последних частей',
|
||||||
|
torrentPrioritizePieceSize: 'Размер диапазона предпросмотра',
|
||||||
|
torrentPrioritizePieceHint: 'Необязательная политика предпросмотра. Каждый включённый диапазон по умолчанию равен 1M и применяется при запуске или повторной попытке.',
|
||||||
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
|
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
|
||||||
torrentEncryptionPolicy: 'Политика шифрования Torrent',
|
torrentEncryptionPolicy: 'Политика шифрования Torrent',
|
||||||
torrentFileAllocation: 'Выделение места для файлов Torrent',
|
torrentFileAllocation: 'Выделение места для файлов Torrent',
|
||||||
@@ -314,6 +330,28 @@ const ru = {
|
|||||||
torrentFileAllocationHint: 'Предварительное выделение резервирует место до передачи; выделение по мере необходимости не делает начальное резервирование.',
|
torrentFileAllocationHint: 'Предварительное выделение резервирует место до передачи; выделение по мере необходимости не делает начальное резервирование.',
|
||||||
torrentEncryptionPolicyHint: 'Применяется при запуске или повторной попытке Torrent. Выберите одну политику, чтобы параметры handshake и шифрования payload оставались согласованными.',
|
torrentEncryptionPolicyHint: 'Применяется при запуске или повторной попытке Torrent. Выберите одну политику, чтобы параметры handshake и шифрования payload оставались согласованными.',
|
||||||
torrentDetails: 'Сведения о Torrent',
|
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…',
|
torrentDetailsLoading: 'Загрузка сведений о Torrent…',
|
||||||
torrentDetailsUnavailable: 'Сведения о Torrent недоступны.',
|
torrentDetailsUnavailable: 'Сведения о Torrent недоступны.',
|
||||||
torrentDetailsDisplayName: 'Отображаемое имя',
|
torrentDetailsDisplayName: 'Отображаемое имя',
|
||||||
@@ -513,12 +551,14 @@ const ru = {
|
|||||||
moveOneFailed: 'Не удалось переместить загрузку в очередь',
|
moveOneFailed: 'Не удалось переместить загрузку в очередь',
|
||||||
copyAddressesFailed: 'Не удалось скопировать адреса',
|
copyAddressesFailed: 'Не удалось скопировать адреса',
|
||||||
copyAddressFailed: 'Не удалось скопировать адрес',
|
copyAddressFailed: 'Не удалось скопировать адрес',
|
||||||
|
copyMagnetFailed: 'Не удалось скопировать magnet-ссылку',
|
||||||
copyPathFailed: 'Не удалось скопировать путь к файлу',
|
copyPathFailed: 'Не удалось скопировать путь к файлу',
|
||||||
missingFileName: 'Отсутствует имя файла',
|
missingFileName: 'Отсутствует имя файла',
|
||||||
redownloadFailed: 'Не удалось скачать повторно',
|
redownloadFailed: 'Не удалось скачать повторно',
|
||||||
startResume: 'Запустить/Возобновить',
|
startResume: 'Запустить/Возобновить',
|
||||||
addToQueue: 'Добавить в очередь',
|
addToQueue: 'Добавить в очередь',
|
||||||
copyAddress: 'Скопировать адрес',
|
copyAddress: 'Скопировать адрес',
|
||||||
|
copyMagnet: 'Копировать magnet-ссылку',
|
||||||
remove: 'Удалить',
|
remove: 'Удалить',
|
||||||
open: 'Открыть',
|
open: 'Открыть',
|
||||||
showInFolder: 'Показать в папке',
|
showInFolder: 'Показать в папке',
|
||||||
@@ -603,8 +643,11 @@ const ru = {
|
|||||||
torrentStopTimeout: 'Останавливать неактивный торрент через',
|
torrentStopTimeout: 'Останавливать неактивный торрент через',
|
||||||
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило.',
|
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило.',
|
||||||
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
|
torrentStopTimeoutInvalid: 'Тайм-аут неактивного торрента должен быть целым числом от 0 до 604800 секунд',
|
||||||
torrentPrioritizePiece: 'Приоритет частей торрента',
|
torrentPrioritizePiece: 'Приоритет первых/последних частей для предпросмотра',
|
||||||
torrentPrioritizePieceHint: 'Сохраняется с торрентом и применяется при следующем запуске или повторной попытке. Укажите начало, конец или оба варианта с размером K или M.',
|
torrentPrioritizePieceHead: 'Приоритет первых частей',
|
||||||
|
torrentPrioritizePieceTail: 'Приоритет последних частей',
|
||||||
|
torrentPrioritizePieceSize: 'Размер диапазона предпросмотра',
|
||||||
|
torrentPrioritizePieceHint: 'Выберите первые, последние части или оба варианта для предпросмотра. Каждый включённый диапазон по умолчанию равен 1M и применяется при следующем запуске или повторной попытке.',
|
||||||
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
|
torrentPrioritizePieceInvalid: 'Приоритет частей торрента должен содержать начало и/или конец с необязательным размером от 1K до 1024M',
|
||||||
torrentEncryptionPolicy: 'Политика шифрования Torrent',
|
torrentEncryptionPolicy: 'Политика шифрования Torrent',
|
||||||
torrentEncryptionPolicyHint: 'Сохраняется вместе с Torrent и применяется при следующем запуске или повторной попытке. Выбранная политика согласует параметры шифрования Aria2.',
|
torrentEncryptionPolicyHint: 'Сохраняется вместе с Torrent и применяется при следующем запуске или повторной попытке. Выбранная политика согласует параметры шифрования Aria2.',
|
||||||
|
|||||||
+49
-6
@@ -96,6 +96,7 @@ const uk = {
|
|||||||
completed: 'Завершено',
|
completed: 'Завершено',
|
||||||
failed: 'Помилка',
|
failed: 'Помилка',
|
||||||
retrying: 'Повторна спроба',
|
retrying: 'Повторна спроба',
|
||||||
|
moving: 'Переміщення даних',
|
||||||
},
|
},
|
||||||
values: {
|
values: {
|
||||||
processing: 'Обробка…',
|
processing: 'Обробка…',
|
||||||
@@ -288,11 +289,16 @@ const uk = {
|
|||||||
torrentPieceProgressSummary: '{{completed}} із {{total}} частин завершено · по {{size}} на частину',
|
torrentPieceProgressSummary: '{{completed}} із {{total}} частин завершено · по {{size}} на частину',
|
||||||
torrentPieceProgressMap: 'Карта завершення частин торрента',
|
torrentPieceProgressMap: 'Карта завершення частин торрента',
|
||||||
torrentWebSeeds: 'Вебсіди торента',
|
torrentWebSeeds: 'Вебсіди торента',
|
||||||
torrentWebSeedsHint: 'Один рядок на сід у форматі індекс файлу|HTTP(S)-URI. Firelink сам розгортає шляхи багатофайлових торентів.',
|
torrentWebSeedsHint: 'Додайте одну базову HTTP(S)-адресу для кожного файлу торента. Firelink сам розгорне шляхи багатофайлового торента.',
|
||||||
torrentWebSeedsPlaceholder: '0|https://дзеркало.example/torrent/',
|
|
||||||
torrentWebSeedsApply: 'Застосувати вебсіди',
|
torrentWebSeedsApply: 'Застосувати вебсіди',
|
||||||
torrentWebSeedsLoading: 'Застосування…',
|
torrentWebSeedsLoading: 'Застосування…',
|
||||||
torrentWebSeedsFailed: 'Не вдалося перевірити або застосувати вебсіди торента.',
|
torrentWebSeedsFailed: 'Не вдалося перевірити або застосувати вебсіди торента.',
|
||||||
|
torrentWebSeedsEmpty: 'Вебсіди не налаштовано.',
|
||||||
|
torrentWebSeedsFile: 'Файл',
|
||||||
|
torrentWebSeedsUri: 'Базова HTTP(S)-адреса',
|
||||||
|
torrentWebSeedsAdd: 'Додати вебсід',
|
||||||
|
torrentWebSeedsRemove: 'Видалити вебсід',
|
||||||
|
torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.',
|
||||||
torrentPeerCount: '{{total}} пірів · {{seeders}} сідів',
|
torrentPeerCount: '{{total}} пірів · {{seeders}} сідів',
|
||||||
torrentPeerDownload: 'Завантаження',
|
torrentPeerDownload: 'Завантаження',
|
||||||
torrentPeerUpload: 'Віддача',
|
torrentPeerUpload: 'Віддача',
|
||||||
@@ -300,12 +306,22 @@ const uk = {
|
|||||||
torrentPeerAmChoking: 'Firelink обмежує',
|
torrentPeerAmChoking: 'Firelink обмежує',
|
||||||
torrentPeerChoking: 'Пір обмежує',
|
torrentPeerChoking: 'Пір обмежує',
|
||||||
torrentPeerShowing: 'Показано {{shown}} із {{total}} пірів.',
|
torrentPeerShowing: 'Показано {{shown}} із {{total}} пірів.',
|
||||||
|
torrentStatistics: 'Статистика торента',
|
||||||
|
torrentUploaded: 'Віддано',
|
||||||
|
torrentRatio: 'Коефіцієнт',
|
||||||
|
torrentSeededDuration: 'Час роздачі',
|
||||||
|
torrentConnectedPeers: 'Піри',
|
||||||
|
torrentSeeders: 'Сіди',
|
||||||
|
torrentUploadSpeed: 'Швидкість віддачі',
|
||||||
seconds: 'секунд',
|
seconds: 'секунд',
|
||||||
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
|
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
|
||||||
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило; зміни застосовуються під час запуску або повторної спроби.',
|
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило; зміни застосовуються під час запуску або повторної спроби.',
|
||||||
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
|
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
|
||||||
torrentPrioritizePiece: 'Пріоритет частин торрента',
|
torrentPrioritizePiece: 'Пріоритет перших/останніх частин для попереднього перегляду',
|
||||||
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду Aria2: початок, кінець або обидва варіанти; для кожного можна вказати розмір, наприклад 1M. Застосовується під час запуску або повторної спроби.',
|
torrentPrioritizePieceHead: 'Пріоритет перших частин',
|
||||||
|
torrentPrioritizePieceTail: 'Пріоритет останніх частин',
|
||||||
|
torrentPrioritizePieceSize: 'Розмір діапазону попереднього перегляду',
|
||||||
|
torrentPrioritizePieceHint: 'Необов’язкова політика попереднього перегляду. Кожен увімкнений діапазон за замовчуванням має розмір 1M і застосовується під час запуску або повторної спроби.',
|
||||||
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
|
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
|
||||||
torrentEncryptionPolicy: 'Політика шифрування Torrent',
|
torrentEncryptionPolicy: 'Політика шифрування Torrent',
|
||||||
torrentFileAllocation: 'Виділення місця для файлів Torrent',
|
torrentFileAllocation: 'Виділення місця для файлів Torrent',
|
||||||
@@ -314,6 +330,28 @@ const uk = {
|
|||||||
torrentFileAllocationHint: 'Попереднє виділення резервує місце до передачі; виділення за потреби не робить початкового резервування.',
|
torrentFileAllocationHint: 'Попереднє виділення резервує місце до передачі; виділення за потреби не робить початкового резервування.',
|
||||||
torrentEncryptionPolicyHint: 'Застосовується під час запуску або повторної спроби Torrent. Виберіть одну політику, щоб параметри handshake і шифрування payload залишалися узгодженими.',
|
torrentEncryptionPolicyHint: 'Застосовується під час запуску або повторної спроби Torrent. Виберіть одну політику, щоб параметри handshake і шифрування payload залишалися узгодженими.',
|
||||||
torrentDetails: 'Відомості про Torrent',
|
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…',
|
torrentDetailsLoading: 'Завантаження відомостей про Torrent…',
|
||||||
torrentDetailsUnavailable: 'Відомості про Torrent недоступні.',
|
torrentDetailsUnavailable: 'Відомості про Torrent недоступні.',
|
||||||
torrentDetailsDisplayName: 'Назва',
|
torrentDetailsDisplayName: 'Назва',
|
||||||
@@ -513,12 +551,14 @@ const uk = {
|
|||||||
moveOneFailed: 'Не вдалося перемістити завантаження до черги',
|
moveOneFailed: 'Не вдалося перемістити завантаження до черги',
|
||||||
copyAddressesFailed: 'Не вдалося скопіювати адреси',
|
copyAddressesFailed: 'Не вдалося скопіювати адреси',
|
||||||
copyAddressFailed: 'Не вдалося скопіювати адресу',
|
copyAddressFailed: 'Не вдалося скопіювати адресу',
|
||||||
|
copyMagnetFailed: 'Не вдалося скопіювати magnet-посилання',
|
||||||
copyPathFailed: 'Не вдалося скопіювати шлях до файлу',
|
copyPathFailed: 'Не вдалося скопіювати шлях до файлу',
|
||||||
missingFileName: 'Ім\'я файлу відсутнє',
|
missingFileName: 'Ім\'я файлу відсутнє',
|
||||||
redownloadFailed: 'Не вдалося повторно завантажити',
|
redownloadFailed: 'Не вдалося повторно завантажити',
|
||||||
startResume: 'Запустити/Відновити',
|
startResume: 'Запустити/Відновити',
|
||||||
addToQueue: 'Додати до черги',
|
addToQueue: 'Додати до черги',
|
||||||
copyAddress: 'Скопіювати адресу',
|
copyAddress: 'Скопіювати адресу',
|
||||||
|
copyMagnet: 'Копіювати magnet-посилання',
|
||||||
remove: 'Видалити',
|
remove: 'Видалити',
|
||||||
open: 'Відкрити',
|
open: 'Відкрити',
|
||||||
showInFolder: 'Показати в папці',
|
showInFolder: 'Показати в папці',
|
||||||
@@ -603,8 +643,11 @@ const uk = {
|
|||||||
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
|
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
|
||||||
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило.',
|
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило.',
|
||||||
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
|
torrentStopTimeoutInvalid: 'Тайм-аут зупинки торрента має бути цілим числом від 0 до 604800 секунд',
|
||||||
torrentPrioritizePiece: 'Пріоритет частин торрента',
|
torrentPrioritizePiece: 'Пріоритет перших/останніх частин для попереднього перегляду',
|
||||||
torrentPrioritizePieceHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. Укажіть початок, кінець або обидва варіанти з розміром K чи M.',
|
torrentPrioritizePieceHead: 'Пріоритет перших частин',
|
||||||
|
torrentPrioritizePieceTail: 'Пріоритет останніх частин',
|
||||||
|
torrentPrioritizePieceSize: 'Розмір діапазону попереднього перегляду',
|
||||||
|
torrentPrioritizePieceHint: 'Виберіть перші, останні частини або обидва варіанти для попереднього перегляду. Кожен увімкнений діапазон за замовчуванням має розмір 1M і застосовується під час наступного запуску або повторної спроби.',
|
||||||
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
|
torrentPrioritizePieceInvalid: 'Пріоритет частин торрента має містити початок і/або кінець із необов’язковим розміром від 1K до 1024M',
|
||||||
torrentEncryptionPolicy: 'Політика шифрування Torrent',
|
torrentEncryptionPolicy: 'Політика шифрування Torrent',
|
||||||
torrentEncryptionPolicyHint: 'Зберігається разом із Torrent і застосовується під час наступного запуску або повторної спроби. Вибрана політика узгоджує параметри шифрування Aria2.',
|
torrentEncryptionPolicyHint: 'Зберігається разом із Torrent і застосовується під час наступного запуску або повторної спроби. Вибрана політика узгоджує параметри шифрування Aria2.',
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ const zhCN = {
|
|||||||
completed: '已完成',
|
completed: '已完成',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
retrying: '重试中',
|
retrying: '重试中',
|
||||||
|
moving: '正在移动数据',
|
||||||
},
|
},
|
||||||
values: {
|
values: {
|
||||||
processing: '处理中…',
|
processing: '处理中…',
|
||||||
@@ -288,11 +289,16 @@ const zhCN = {
|
|||||||
torrentPieceProgressSummary: '{{completed}}/{{total}} 个分片已完成 · 每片 {{size}}',
|
torrentPieceProgressSummary: '{{completed}}/{{total}} 个分片已完成 · 每片 {{size}}',
|
||||||
torrentPieceProgressMap: 'Torrent 分片完成度地图',
|
torrentPieceProgressMap: 'Torrent 分片完成度地图',
|
||||||
torrentWebSeeds: 'Torrent Web 做种',
|
torrentWebSeeds: 'Torrent Web 做种',
|
||||||
torrentWebSeedsHint: '每行一个做种,格式为文件索引|HTTP(S) URI。多文件路径由 Firelink 原生展开。',
|
torrentWebSeedsHint: '为每个 Torrent 文件添加一个 HTTP(S) 基础地址。多文件路径由 Firelink 原生展开。',
|
||||||
torrentWebSeedsPlaceholder: '0|https://镜像.example/torrent/',
|
|
||||||
torrentWebSeedsApply: '应用 Web 做种',
|
torrentWebSeedsApply: '应用 Web 做种',
|
||||||
torrentWebSeedsLoading: '正在应用…',
|
torrentWebSeedsLoading: '正在应用…',
|
||||||
torrentWebSeedsFailed: '无法验证或应用 Torrent Web 做种。',
|
torrentWebSeedsFailed: '无法验证或应用 Torrent Web 做种。',
|
||||||
|
torrentWebSeedsEmpty: '尚未配置 Web 做种。',
|
||||||
|
torrentWebSeedsFile: '文件',
|
||||||
|
torrentWebSeedsUri: 'HTTP(S) 基础地址',
|
||||||
|
torrentWebSeedsAdd: '添加 Web 做种',
|
||||||
|
torrentWebSeedsRemove: '移除 Web 做种',
|
||||||
|
torrentWebSeedsInvalid: '每行 Web 做种都需要有效的 Torrent 文件和不含凭据或片段的 HTTP(S) 基础地址。',
|
||||||
torrentPeerCount: '{{total}} 个节点 · {{seeders}} 个做种节点',
|
torrentPeerCount: '{{total}} 个节点 · {{seeders}} 个做种节点',
|
||||||
torrentPeerDownload: '下载',
|
torrentPeerDownload: '下载',
|
||||||
torrentPeerUpload: '上传',
|
torrentPeerUpload: '上传',
|
||||||
@@ -300,12 +306,22 @@ const zhCN = {
|
|||||||
torrentPeerAmChoking: 'Firelink 限制中',
|
torrentPeerAmChoking: 'Firelink 限制中',
|
||||||
torrentPeerChoking: '对等节点限制中',
|
torrentPeerChoking: '对等节点限制中',
|
||||||
torrentPeerShowing: '显示 {{total}} 个节点中的 {{shown}} 个。',
|
torrentPeerShowing: '显示 {{total}} 个节点中的 {{shown}} 个。',
|
||||||
|
torrentStatistics: '种子统计',
|
||||||
|
torrentUploaded: '已上传',
|
||||||
|
torrentRatio: '分享率',
|
||||||
|
torrentSeededDuration: '做种时长',
|
||||||
|
torrentConnectedPeers: '连接数',
|
||||||
|
torrentSeeders: '种子数',
|
||||||
|
torrentUploadSpeed: '上传速度',
|
||||||
seconds: '秒',
|
seconds: '秒',
|
||||||
torrentStopTimeout: '在此时间后停止无速度 Torrent',
|
torrentStopTimeout: '在此时间后停止无速度 Torrent',
|
||||||
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用;更改会在 Torrent 启动或重试时应用。',
|
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用;更改会在 Torrent 启动或重试时应用。',
|
||||||
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
|
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
|
||||||
torrentPrioritizePiece: '优先下载 Torrent 片段',
|
torrentPrioritizePiece: '为预览优先下载开头/结尾片段',
|
||||||
torrentPrioritizePieceHint: '可选的 Aria2 预览策略:开头、结尾或两者;每项可使用 1M 等大小。Torrent 启动或重试时应用。',
|
torrentPrioritizePieceHead: '优先下载开头片段',
|
||||||
|
torrentPrioritizePieceTail: '优先下载结尾片段',
|
||||||
|
torrentPrioritizePieceSize: '预览片段范围大小',
|
||||||
|
torrentPrioritizePieceHint: '可选的预览策略。每个启用的范围默认为 1M,并在 Torrent 启动或重试时应用。',
|
||||||
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
|
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
|
||||||
torrentEncryptionPolicy: 'Torrent 加密策略',
|
torrentEncryptionPolicy: 'Torrent 加密策略',
|
||||||
torrentFileAllocation: 'Torrent 文件分配',
|
torrentFileAllocation: 'Torrent 文件分配',
|
||||||
@@ -314,6 +330,28 @@ const zhCN = {
|
|||||||
torrentFileAllocationHint: '预分配会在传输前为选中文件预留空间;按需分配不会进行初始磁盘预留。',
|
torrentFileAllocationHint: '预分配会在传输前为选中文件预留空间;按需分配不会进行初始磁盘预留。',
|
||||||
torrentEncryptionPolicyHint: '在 Torrent 启动或重试时应用。选择单一策略,确保握手和 payload 加密设置保持一致。',
|
torrentEncryptionPolicyHint: '在 Torrent 启动或重试时应用。选择单一策略,确保握手和 payload 加密设置保持一致。',
|
||||||
torrentDetails: 'Torrent 详细信息',
|
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 详细信息…',
|
torrentDetailsLoading: '正在加载 Torrent 详细信息…',
|
||||||
torrentDetailsUnavailable: 'Torrent 详细信息不可用。',
|
torrentDetailsUnavailable: 'Torrent 详细信息不可用。',
|
||||||
torrentDetailsDisplayName: '显示名称',
|
torrentDetailsDisplayName: '显示名称',
|
||||||
@@ -513,12 +551,14 @@ const zhCN = {
|
|||||||
moveOneFailed: '无法将下载移动到队列',
|
moveOneFailed: '无法将下载移动到队列',
|
||||||
copyAddressesFailed: '无法复制地址',
|
copyAddressesFailed: '无法复制地址',
|
||||||
copyAddressFailed: '无法复制地址',
|
copyAddressFailed: '无法复制地址',
|
||||||
|
copyMagnetFailed: '无法复制磁力链接',
|
||||||
copyPathFailed: '无法复制文件路径',
|
copyPathFailed: '无法复制文件路径',
|
||||||
missingFileName: '缺少文件名',
|
missingFileName: '缺少文件名',
|
||||||
redownloadFailed: '重新下载失败',
|
redownloadFailed: '重新下载失败',
|
||||||
startResume: '开始/恢复',
|
startResume: '开始/恢复',
|
||||||
addToQueue: '添加到队列',
|
addToQueue: '添加到队列',
|
||||||
copyAddress: '复制地址',
|
copyAddress: '复制地址',
|
||||||
|
copyMagnet: '复制磁力链接',
|
||||||
remove: '移除',
|
remove: '移除',
|
||||||
open: '打开',
|
open: '打开',
|
||||||
showInFolder: '在文件夹中显示',
|
showInFolder: '在文件夹中显示',
|
||||||
@@ -603,8 +643,11 @@ const zhCN = {
|
|||||||
torrentStopTimeout: '在此时间后停止无速度 Torrent',
|
torrentStopTimeout: '在此时间后停止无速度 Torrent',
|
||||||
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用。',
|
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用。',
|
||||||
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
|
torrentStopTimeoutInvalid: 'Torrent 停止超时必须是 0 到 604800 秒之间的整数',
|
||||||
torrentPrioritizePiece: '优先下载 Torrent 片段',
|
torrentPrioritizePiece: '为预览优先下载开头/结尾片段',
|
||||||
torrentPrioritizePieceHint: '随 Torrent 保存,并在下次启动或重试时应用。可使用开头、结尾或两者,并可选 K 或 M 大小。',
|
torrentPrioritizePieceHead: '优先下载开头片段',
|
||||||
|
torrentPrioritizePieceTail: '优先下载结尾片段',
|
||||||
|
torrentPrioritizePieceSize: '预览片段范围大小',
|
||||||
|
torrentPrioritizePieceHint: '选择开头片段、结尾片段或两者用于预览。每个启用的范围默认为 1M,并在下次启动或重试时应用。',
|
||||||
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
|
torrentPrioritizePieceInvalid: 'Torrent 片段优先级必须使用开头和/或结尾,并可选 1K 到 1024M 的大小',
|
||||||
torrentEncryptionPolicy: 'Torrent 加密策略',
|
torrentEncryptionPolicy: 'Torrent 加密策略',
|
||||||
torrentEncryptionPolicyHint: '随 Torrent 保存,并在下次启动或重试时应用。所选策略会保持 Aria2 加密设置一致。',
|
torrentEncryptionPolicyHint: '随 Torrent 保存,并在下次启动或重试时应用。所选策略会保持 Aria2 加密设置一致。',
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type { TorrentPieceProgressSnapshot } from './bindings/TorrentPieceProgre
|
|||||||
import type { TorrentWebSeed } from './bindings/TorrentWebSeed';
|
import type { TorrentWebSeed } from './bindings/TorrentWebSeed';
|
||||||
import type { TorrentDetails } from './bindings/TorrentDetails';
|
import type { TorrentDetails } from './bindings/TorrentDetails';
|
||||||
import type { TorrentFileSelectionSnapshot } from './bindings/TorrentFileSelectionSnapshot';
|
import type { TorrentFileSelectionSnapshot } from './bindings/TorrentFileSelectionSnapshot';
|
||||||
|
import type { TorrentAvailabilitySnapshot } from './bindings/TorrentAvailabilitySnapshot';
|
||||||
|
|
||||||
type CommandMap = {
|
type CommandMap = {
|
||||||
fetch_metadata: {
|
fetch_metadata: {
|
||||||
@@ -89,7 +90,12 @@ type CommandMap = {
|
|||||||
get_torrent_file_selection: { args: { id: string }; result: TorrentFileSelectionSnapshot };
|
get_torrent_file_selection: { args: { id: string }; result: TorrentFileSelectionSnapshot };
|
||||||
set_torrent_file_selection: { args: { id: string; selected_indices: number[] | null }; 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_details: { args: { id: string }; result: TorrentDetails };
|
||||||
|
get_torrent_availability: { args: { id: string }; result: TorrentAvailabilitySnapshot };
|
||||||
verify_torrent_data: { args: { id: string }; result: void };
|
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[] };
|
get_torrent_web_seeds: { args: { id: string }; result: TorrentWebSeed[] };
|
||||||
set_torrent_web_seeds: { args: { id: string; seeds: TorrentWebSeed[] }; 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 };
|
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 };
|
'schedule-trigger': { action: 'start' | 'stop'; key: string };
|
||||||
'download-progress': DownloadProgressEvent;
|
'download-progress': DownloadProgressEvent;
|
||||||
'download-state': DownloadStateEvent;
|
'download-state': DownloadStateEvent;
|
||||||
|
'torrent-move-progress': import('./bindings/TorrentMoveProgressEvent').TorrentMoveProgressEvent;
|
||||||
'download-complete': string;
|
'download-complete': string;
|
||||||
'download-failed': string;
|
'download-failed': string;
|
||||||
'extension-add-download': ExtensionDownload;
|
'extension-add-download': ExtensionDownload;
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
|||||||
|
|
||||||
interface DownloadProgressState {
|
interface DownloadProgressState {
|
||||||
progressMap: Record<string, DownloadProgressEvent>;
|
progressMap: Record<string, DownloadProgressEvent>;
|
||||||
|
moveProgressMap: Record<string, number>;
|
||||||
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
|
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
|
||||||
clearDownloadProgress: (id: string) => void;
|
clearDownloadProgress: (id: string) => void;
|
||||||
|
setMoveProgress: (id: string, fraction: number) => void;
|
||||||
|
clearMoveProgress: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
|
export const useDownloadProgressStore = create<DownloadProgressState>((set) => ({
|
||||||
progressMap: {},
|
progressMap: {},
|
||||||
|
moveProgressMap: {},
|
||||||
updateDownloadProgress: (id, payload) =>
|
updateDownloadProgress: (id, payload) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
progressMap: {
|
progressMap: {
|
||||||
@@ -18,9 +22,22 @@ export const useDownloadProgressStore = create<DownloadProgressState>((set) => (
|
|||||||
})),
|
})),
|
||||||
clearDownloadProgress: (id) =>
|
clearDownloadProgress: (id) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!(id in state.progressMap)) return state;
|
if (!(id in state.progressMap) && !(id in state.moveProgressMap)) return state;
|
||||||
const next = { ...state.progressMap };
|
const next = { ...state.progressMap };
|
||||||
delete next[id];
|
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 };
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe('useDownloadProgressStore', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
|
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
|
||||||
useDownloadProgressStore.setState({ progressMap: {} });
|
useDownloadProgressStore.setState({ progressMap: {}, moveProgressMap: {} });
|
||||||
clearDownloadControlIntents();
|
clearDownloadControlIntents();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ describe('useDownloadProgressStore', () => {
|
|||||||
const first = initDownloadListener();
|
const first = initDownloadListener();
|
||||||
const second = initDownloadListener();
|
const second = initDownloadListener();
|
||||||
|
|
||||||
expect(ipc.listenEvent).toHaveBeenCalledTimes(3);
|
expect(ipc.listenEvent).toHaveBeenCalledTimes(4);
|
||||||
|
|
||||||
const releaseFirst = await first;
|
const releaseFirst = await first;
|
||||||
const releaseSecond = await second;
|
const releaseSecond = await second;
|
||||||
@@ -51,7 +51,7 @@ describe('useDownloadProgressStore', () => {
|
|||||||
expect(unlisten).not.toHaveBeenCalled();
|
expect(unlisten).not.toHaveBeenCalled();
|
||||||
|
|
||||||
releaseSecond();
|
releaseSecond();
|
||||||
expect(unlisten).toHaveBeenCalledTimes(3);
|
expect(unlisten).toHaveBeenCalledTimes(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores late progress and opposite terminal events from an older lifecycle', async () => {
|
it('ignores late progress and opposite terminal events from an older lifecycle', async () => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export { useDownloadProgressStore } from './downloadProgressStore';
|
|||||||
|
|
||||||
let unlistenProgress: UnlistenFn | null = null;
|
let unlistenProgress: UnlistenFn | null = null;
|
||||||
let unlistenState: UnlistenFn | null = null;
|
let unlistenState: UnlistenFn | null = null;
|
||||||
|
let unlistenMoveProgress: UnlistenFn | null = null;
|
||||||
let unlistenTray: UnlistenFn | null = null;
|
let unlistenTray: UnlistenFn | null = null;
|
||||||
let listenerSetup: Promise<void> | null = null;
|
let listenerSetup: Promise<void> | null = null;
|
||||||
let listenerConsumers = 0;
|
let listenerConsumers = 0;
|
||||||
@@ -25,6 +26,8 @@ const disposeDownloadListeners = () => {
|
|||||||
unlistenProgress = null;
|
unlistenProgress = null;
|
||||||
unlistenState?.();
|
unlistenState?.();
|
||||||
unlistenState = null;
|
unlistenState = null;
|
||||||
|
unlistenMoveProgress?.();
|
||||||
|
unlistenMoveProgress = null;
|
||||||
unlistenTray?.();
|
unlistenTray?.();
|
||||||
unlistenTray = null;
|
unlistenTray = null;
|
||||||
listenerSetup = null;
|
listenerSetup = null;
|
||||||
@@ -71,6 +74,20 @@ const startDownloadListeners = async () => {
|
|||||||
if (payload.total_is_estimate !== null && payload.total_is_estimate !== undefined) {
|
if (payload.total_is_estimate !== null && payload.total_is_estimate !== undefined) {
|
||||||
updates.totalIsEstimate = payload.total_is_estimate;
|
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(
|
const observedDownloadedBytes = Math.max(
|
||||||
current.downloadedBytes ?? 0,
|
current.downloadedBytes ?? 0,
|
||||||
payload.downloaded_bytes ?? 0
|
payload.downloaded_bytes ?? 0
|
||||||
@@ -103,6 +120,9 @@ const startDownloadListeners = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const status = payload.status as DownloadStatus;
|
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
|
// resume_download queues the row before the backend can emit its new
|
||||||
// active state. Paused events already emitted by the old lifecycle may
|
// 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.
|
// applied while the transition is in flight.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (status === 'downloading' || status === 'processing' ||
|
if (status === 'downloading' || status === 'processing' || status === 'moving' ||
|
||||||
status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' ||
|
status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' ||
|
||||||
status === 'completed' || status === 'failed') {
|
status === 'completed' || status === 'failed') {
|
||||||
clearDownloadControlIntent(payload.id, 'resume');
|
clearDownloadControlIntent(payload.id, 'resume');
|
||||||
@@ -136,13 +156,14 @@ const startDownloadListeners = async () => {
|
|||||||
// before asking the backend to resume, so an active event arriving while
|
// before asking the backend to resume, so an active event arriving while
|
||||||
// the row is still paused cannot represent a new lifecycle.
|
// the row is still paused cannot represent a new lifecycle.
|
||||||
if ((current.status === 'completed' || current.status === 'failed') &&
|
if ((current.status === 'completed' || current.status === 'failed') &&
|
||||||
status !== current.status) {
|
status !== current.status && status !== 'moving') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (current.status === 'paused' &&
|
if (current.status === 'paused' &&
|
||||||
status !== 'paused' &&
|
status !== 'paused' &&
|
||||||
status !== 'completed' &&
|
status !== 'completed' &&
|
||||||
status !== 'failed') {
|
status !== 'failed' &&
|
||||||
|
status !== 'moving') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (current.status === 'seeding' &&
|
if (current.status === 'seeding' &&
|
||||||
@@ -150,7 +171,8 @@ const startDownloadListeners = async () => {
|
|||||||
status !== 'waitingToSeed' &&
|
status !== 'waitingToSeed' &&
|
||||||
status !== 'paused' &&
|
status !== 'paused' &&
|
||||||
status !== 'completed' &&
|
status !== 'completed' &&
|
||||||
status !== 'failed') {
|
status !== 'failed' &&
|
||||||
|
status !== 'moving') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,8 +180,14 @@ const startDownloadListeners = async () => {
|
|||||||
if (['queued', 'retrying', 'completed', 'failed', 'paused', 'waitingToSeed'].includes(status)) {
|
if (['queued', 'retrying', 'completed', 'failed', 'paused', 'waitingToSeed'].includes(status)) {
|
||||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
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> = {
|
const updates: Partial<DownloadItem> = {
|
||||||
status,
|
status,
|
||||||
|
torrentMoveRestoreStatus: moveRestoreStatus,
|
||||||
...(progress ? {
|
...(progress ? {
|
||||||
fraction: progress.fraction,
|
fraction: progress.fraction,
|
||||||
...(progress.downloaded_bytes != null
|
...(progress.downloaded_bytes != null
|
||||||
@@ -221,6 +249,17 @@ const startDownloadListeners = async () => {
|
|||||||
mainStore.unregisterBackendIds([payload.id]);
|
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) => {
|
listen('tray-action', (event) => {
|
||||||
const mainStore = useDownloadStore.getState();
|
const mainStore = useDownloadStore.getState();
|
||||||
if (event.payload === 'pause-all') {
|
if (event.payload === 'pause-all') {
|
||||||
@@ -241,13 +280,15 @@ const startDownloadListeners = async () => {
|
|||||||
throw failedRegistration.reason;
|
throw failedRegistration.reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [progress, state, tray] = registrations as [
|
const [progress, state, moveProgress, tray] = registrations as [
|
||||||
|
PromiseFulfilledResult<UnlistenFn>,
|
||||||
PromiseFulfilledResult<UnlistenFn>,
|
PromiseFulfilledResult<UnlistenFn>,
|
||||||
PromiseFulfilledResult<UnlistenFn>,
|
PromiseFulfilledResult<UnlistenFn>,
|
||||||
PromiseFulfilledResult<UnlistenFn>,
|
PromiseFulfilledResult<UnlistenFn>,
|
||||||
];
|
];
|
||||||
unlistenProgress = progress.value;
|
unlistenProgress = progress.value;
|
||||||
unlistenState = state.value;
|
unlistenState = state.value;
|
||||||
|
unlistenMoveProgress = moveProgress.value;
|
||||||
unlistenTray = tray.value;
|
unlistenTray = tray.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -938,6 +938,24 @@ describe('useDownloadStore', () => {
|
|||||||
expect(normalized.torrentEncryptionPolicy).toBeUndefined();
|
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 () => {
|
it('normalizes proxy settings for download dispatch', async () => {
|
||||||
expect(normalizeCustomProxy('127.0.0.1', 8080)).toBe('http://127.0.0.1:8080');
|
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');
|
expect(normalizeCustomProxy('http://proxy.local:9000', 8080)).toBe('http://proxy.local:9000');
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
|
|||||||
torrent_upload_limit: item.torrentUploadLimit || undefined,
|
torrent_upload_limit: item.torrentUploadLimit || undefined,
|
||||||
torrent_max_peers: item.torrentMaxPeers,
|
torrent_max_peers: item.torrentMaxPeers,
|
||||||
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
|
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_trackers: item.torrentTrackers || undefined,
|
||||||
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
|
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
|
||||||
torrent_tracker_connect_timeout: item.torrentTrackerConnectTimeout,
|
torrent_tracker_connect_timeout: item.torrentTrackerConnectTimeout,
|
||||||
@@ -636,6 +636,18 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
|||||||
rawSeedRemaining >= 0
|
rawSeedRemaining >= 0
|
||||||
? rawSeedRemaining
|
? rawSeedRemaining
|
||||||
: undefined;
|
: 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 rawMaxPeers = download.torrentMaxPeers as unknown;
|
||||||
const normalizedMaxPeers = typeof rawMaxPeers === 'number' &&
|
const normalizedMaxPeers = typeof rawMaxPeers === 'number' &&
|
||||||
Number.isInteger(rawMaxPeers) &&
|
Number.isInteger(rawMaxPeers) &&
|
||||||
@@ -654,6 +666,17 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
|||||||
(seed as { uri: string }).uri.length <= 2048
|
(seed as { uri: string }).uri.length <= 2048
|
||||||
).slice(0, 256)
|
).slice(0, 256)
|
||||||
: undefined;
|
: 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 rawPeerSpeedLimit = download.torrentPeerSpeedLimit as unknown;
|
||||||
const normalizedPeerSpeedLimit = typeof rawPeerSpeedLimit === 'string'
|
const normalizedPeerSpeedLimit = typeof rawPeerSpeedLimit === 'string'
|
||||||
? normalizeSpeedLimitForBackend(rawPeerSpeedLimit) || undefined
|
? normalizeSpeedLimitForBackend(rawPeerSpeedLimit) || undefined
|
||||||
@@ -697,6 +720,24 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
|||||||
const normalizedFileAllocation = normalizeTorrentFileAllocation(rawFileAllocation);
|
const normalizedFileAllocation = normalizeTorrentFileAllocation(rawFileAllocation);
|
||||||
const rawVerifyOnly = download.torrentVerifyOnly as unknown;
|
const rawVerifyOnly = download.torrentVerifyOnly as unknown;
|
||||||
const normalizedVerifyOnly = rawVerifyOnly === true ? true : undefined;
|
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 rawVerifyRestoreStatus = download.torrentVerifyRestoreStatus as unknown;
|
||||||
const normalizedVerifyRestoreStatus = normalizedVerifyOnly === true
|
const normalizedVerifyRestoreStatus = normalizedVerifyOnly === true
|
||||||
&& typeof rawVerifyRestoreStatus === 'string'
|
&& typeof rawVerifyRestoreStatus === 'string'
|
||||||
@@ -704,7 +745,10 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
|||||||
? rawVerifyRestoreStatus
|
? rawVerifyRestoreStatus
|
||||||
: undefined;
|
: undefined;
|
||||||
const normalizedOptions = rawSeedRemaining !== normalizedSeedRemaining ||
|
const normalizedOptions = rawSeedRemaining !== normalizedSeedRemaining ||
|
||||||
|
rawUploadedBytes !== normalizedUploadedBytes ||
|
||||||
|
rawSeededSeconds !== normalizedSeededSeconds ||
|
||||||
rawWebSeeds !== normalizedWebSeeds ||
|
rawWebSeeds !== normalizedWebSeeds ||
|
||||||
|
rawNativeWebSeeds !== normalizedNativeWebSeeds ||
|
||||||
rawMaxPeers !== normalizedMaxPeers ||
|
rawMaxPeers !== normalizedMaxPeers ||
|
||||||
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
|
rawPeerSpeedLimit !== normalizedPeerSpeedLimit ||
|
||||||
rawCheckIntegrity !== normalizedCheckIntegrity ||
|
rawCheckIntegrity !== normalizedCheckIntegrity ||
|
||||||
@@ -719,11 +763,19 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
|||||||
rawEncryptionPolicy !== normalizedEncryptionPolicy ||
|
rawEncryptionPolicy !== normalizedEncryptionPolicy ||
|
||||||
rawFileAllocation !== normalizedFileAllocation ||
|
rawFileAllocation !== normalizedFileAllocation ||
|
||||||
rawVerifyOnly !== normalizedVerifyOnly ||
|
rawVerifyOnly !== normalizedVerifyOnly ||
|
||||||
|
rawRelocationCheckPending !== normalizedRelocationCheckPending ||
|
||||||
|
rawMoveDestination !== normalizedMoveDestination ||
|
||||||
|
rawMoveRestoreStatus !== normalizedMoveRestoreStatus ||
|
||||||
|
recoveredMoveStatus !== download.status ||
|
||||||
rawVerifyRestoreStatus !== normalizedVerifyRestoreStatus
|
rawVerifyRestoreStatus !== normalizedVerifyRestoreStatus
|
||||||
? {
|
? {
|
||||||
...download,
|
...download,
|
||||||
|
status: recoveredMoveStatus,
|
||||||
torrentSeedRemaining: normalizedSeedRemaining,
|
torrentSeedRemaining: normalizedSeedRemaining,
|
||||||
|
torrentUploadedBytes: normalizedUploadedBytes,
|
||||||
|
torrentSeededSeconds: normalizedSeededSeconds,
|
||||||
torrentWebSeeds: normalizedWebSeeds,
|
torrentWebSeeds: normalizedWebSeeds,
|
||||||
|
torrentWebSeedsNative: normalizedNativeWebSeeds,
|
||||||
torrentMaxPeers: normalizedMaxPeers,
|
torrentMaxPeers: normalizedMaxPeers,
|
||||||
torrentPeerSpeedLimit: normalizedPeerSpeedLimit,
|
torrentPeerSpeedLimit: normalizedPeerSpeedLimit,
|
||||||
torrentCheckIntegrity: normalizedCheckIntegrity,
|
torrentCheckIntegrity: normalizedCheckIntegrity,
|
||||||
@@ -738,9 +790,14 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down
|
|||||||
torrentEncryptionPolicy: normalizedEncryptionPolicy,
|
torrentEncryptionPolicy: normalizedEncryptionPolicy,
|
||||||
torrentFileAllocation: normalizedFileAllocation,
|
torrentFileAllocation: normalizedFileAllocation,
|
||||||
torrentVerifyOnly: normalizedVerifyOnly,
|
torrentVerifyOnly: normalizedVerifyOnly,
|
||||||
|
torrentRelocationCheckPending: normalizedRelocationCheckPending,
|
||||||
|
torrentMoveDestination: normalizedMoveDestination,
|
||||||
|
torrentMoveRestoreStatus: normalizedMoveRestoreStatus,
|
||||||
torrentVerifyRestoreStatus: normalizedVerifyRestoreStatus
|
torrentVerifyRestoreStatus: normalizedVerifyRestoreStatus
|
||||||
}
|
}
|
||||||
: download;
|
: recoveredMoveStatus !== download.status
|
||||||
|
? { ...download, status: recoveredMoveStatus, torrentMoveRestoreStatus: normalizedMoveRestoreStatus }
|
||||||
|
: download;
|
||||||
|
|
||||||
return hasStaleTemporaryMediaEstimate(normalizedOptions)
|
return hasStaleTemporaryMediaEstimate(normalizedOptions)
|
||||||
? {
|
? {
|
||||||
@@ -2300,7 +2357,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
|||||||
torrent_upload_limit: item.torrentUploadLimit || undefined,
|
torrent_upload_limit: item.torrentUploadLimit || undefined,
|
||||||
torrent_max_peers: item.torrentMaxPeers,
|
torrent_max_peers: item.torrentMaxPeers,
|
||||||
torrent_peer_speed_limit: item.torrentPeerSpeedLimit || undefined,
|
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_trackers: item.torrentTrackers || undefined,
|
||||||
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
|
torrent_exclude_trackers: item.torrentExcludeTrackers || undefined,
|
||||||
torrent_tracker_connect_timeout: item.torrentTrackerConnectTimeout,
|
torrent_tracker_connect_timeout: item.torrentTrackerConnectTimeout,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from './downloads';
|
} from './downloads';
|
||||||
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata';
|
||||||
import type { TorrentFile } from '../bindings/TorrentFile';
|
import type { TorrentFile } from '../bindings/TorrentFile';
|
||||||
|
import type { TorrentWebSeedDraft } from './downloads';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { localePluralVariant } from '../i18n/locales';
|
import { localePluralVariant } from '../i18n/locales';
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ export interface AddDownloadDraftRow {
|
|||||||
torrentCheckIntegrity?: boolean;
|
torrentCheckIntegrity?: boolean;
|
||||||
torrentTrackers?: string;
|
torrentTrackers?: string;
|
||||||
torrentExcludeTrackers?: string;
|
torrentExcludeTrackers?: string;
|
||||||
|
torrentWebSeedRows?: TorrentWebSeedDraft[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export const startActionLabel = (status: DownloadStatus): 'Start' | 'Resume' =>
|
|||||||
status === 'ready' || status === 'staged' || status === 'failed' ? 'Start' : 'Resume';
|
status === 'ready' || status === 'staged' || status === 'failed' ? 'Start' : 'Resume';
|
||||||
|
|
||||||
export const isTransferLocked = (status: DownloadStatus): boolean =>
|
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 =>
|
export const isIdentityLocked = (status: DownloadStatus): boolean =>
|
||||||
isTransferLocked(status) || status === 'completed';
|
isTransferLocked(status) || status === 'completed';
|
||||||
|
|||||||
@@ -34,6 +34,30 @@ export const formatDownloadBytes = (bytes: number): string => {
|
|||||||
return `${formatDownloadBytesInUnit(bytes, unitIndex)} ${BYTE_UNITS[unitIndex]}`;
|
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 =>
|
export const formatDownloadTotal = (display: DownloadSizeDisplay): string =>
|
||||||
display.total && display.unit
|
display.total && display.unit
|
||||||
? `${display.totalIsEstimate ? '~' : ''}${display.total} ${display.unit}`
|
? `${display.totalIsEstimate ? '~' : ''}${display.total} ${display.unit}`
|
||||||
@@ -72,6 +96,7 @@ export const downloadProgressColorClass = (status: string): string => {
|
|||||||
case 'failed':
|
case 'failed':
|
||||||
return 'download-status-failed';
|
return 'download-status-failed';
|
||||||
case 'processing':
|
case 'processing':
|
||||||
|
case 'moving':
|
||||||
return 'download-status-processing';
|
return 'download-status-processing';
|
||||||
case 'verifying':
|
case 'verifying':
|
||||||
return 'download-status-processing';
|
return 'download-status-processing';
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ const isFreshDownloadStatus = (status: DownloadItem['status']): boolean =>
|
|||||||
status === 'seeding' ||
|
status === 'seeding' ||
|
||||||
status === 'processing' ||
|
status === 'processing' ||
|
||||||
status === 'verifying' ||
|
status === 'verifying' ||
|
||||||
status === 'retrying';
|
status === 'retrying' ||
|
||||||
|
status === 'moving';
|
||||||
|
|
||||||
const hasPositiveProgress = (download: DownloadItem): boolean =>
|
const hasPositiveProgress = (download: DownloadItem): boolean =>
|
||||||
typeof download.fraction === 'number' &&
|
typeof download.fraction === 'number' &&
|
||||||
@@ -81,7 +82,7 @@ export const summarizeDownloads = (
|
|||||||
|
|
||||||
for (const download of downloads) {
|
for (const download of downloads) {
|
||||||
const state = effectiveByteState(download, progressMap[download.id]);
|
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) {
|
if (state.downloadedBytes === undefined) {
|
||||||
downloadedKnown = false;
|
downloadedKnown = false;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import {
|
|||||||
normalizeTorrentEncryptionPolicy,
|
normalizeTorrentEncryptionPolicy,
|
||||||
normalizeTorrentMaxOpenFiles,
|
normalizeTorrentMaxOpenFiles,
|
||||||
normalizeTorrentPrioritizePiece,
|
normalizeTorrentPrioritizePiece,
|
||||||
|
normalizeTorrentWebSeedDrafts,
|
||||||
|
parseTorrentPreviewPriority,
|
||||||
|
serializeTorrentPreviewPriority,
|
||||||
|
torrentWebSeedDraftsFromSeeds,
|
||||||
normalizeTorrentTrackerInterval,
|
normalizeTorrentTrackerInterval,
|
||||||
normalizeTorrentTrackerTimeout,
|
normalizeTorrentTrackerTimeout,
|
||||||
redactDownloadForPersistence,
|
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', () => {
|
describe('Torrent encryption policy validation', () => {
|
||||||
it('accepts only the canonical policy states', () => {
|
it('accepts only the canonical policy states', () => {
|
||||||
expect(normalizeTorrentEncryptionPolicy('disabled')).toBe('disabled');
|
expect(normalizeTorrentEncryptionPolicy('disabled')).toBe('disabled');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { DownloadCategory } from '../bindings/DownloadCategory';
|
import type { DownloadCategory } from '../bindings/DownloadCategory';
|
||||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
|
import type { TorrentWebSeed } from '../bindings/TorrentWebSeed';
|
||||||
export type { DownloadCategory } from '../bindings/DownloadCategory';
|
export type { DownloadCategory } from '../bindings/DownloadCategory';
|
||||||
|
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
@@ -34,6 +35,7 @@ const ACTIVE_DOWNLOAD_STATUSES: ReadonlySet<DownloadStatus> = new Set([
|
|||||||
'seeding',
|
'seeding',
|
||||||
'waitingToSeed',
|
'waitingToSeed',
|
||||||
'retrying',
|
'retrying',
|
||||||
|
'moving',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
|
export const isActiveDownloadStatus = (status: DownloadStatus): boolean =>
|
||||||
@@ -214,6 +216,8 @@ const MAX_TORRENT_TRACKERS = 64;
|
|||||||
const MAX_TORRENT_TRACKER_BYTES = 16 * 1024;
|
const MAX_TORRENT_TRACKER_BYTES = 16 * 1024;
|
||||||
export const MAX_TORRENT_STOP_TIMEOUT = 7 * 24 * 60 * 60;
|
export const MAX_TORRENT_STOP_TIMEOUT = 7 * 24 * 60 * 60;
|
||||||
const MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB = 1024;
|
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 normalizeTorrentPiecePrioritySize = (value: string): string | null => {
|
||||||
const match = value.trim().match(/^(\d+)\s*([km])$/i);
|
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;
|
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.
|
* Performs the same user-facing safety checks as the native tracker boundary.
|
||||||
* The Rust validator remains authoritative because persisted data can bypass
|
* The Rust validator remains authoritative because persisted data can bypass
|
||||||
|
|||||||
Reference in New Issue
Block a user