mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-02 15:39:37 +00:00
feat(torrents): add peer diagnostics
This commit is contained in:
+7
-5
@@ -18,6 +18,10 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar
|
||||
- Optional seeding by time and/or ratio, upload progress, upload limits,
|
||||
seeders telemetry, per-Torrent maximum peers, and the Aria2
|
||||
`bt-request-peer-speed-limit` threshold.
|
||||
- Bounded, read-only Torrent peer diagnostics through `aria2.getPeers`.
|
||||
Firelink discards peer IPs, ports, IDs, and bitfields at the native boundary;
|
||||
the selected-Torrent detail view exposes only operational speeds, seeder,
|
||||
and choking flags, with a bounded display count.
|
||||
- Global DHT, IPv6 DHT, PEX, and Local Peer Discovery toggles.
|
||||
- Optional piece-integrity verification, including the explicit policy that
|
||||
disables unverified seeding when verification is requested.
|
||||
@@ -36,10 +40,7 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar
|
||||
|
||||
### Tier 0 — reliability and user-visible control
|
||||
|
||||
1. **Peer diagnostics** — expose `aria2.getPeers` as bounded, redacted,
|
||||
read-only detail for the selected Torrent. Keep the current counts as the
|
||||
fast summary and treat peer IPs/IDs as sensitive display data.
|
||||
2. **Tracker exclusion** — add `bt-exclude-tracker` alongside the existing
|
||||
1. **Tracker exclusion** — add `bt-exclude-tracker` alongside the existing
|
||||
additional-tracker list. It must be persisted, re-normalized, and re-applied
|
||||
on every retry/GID replacement. Document that it filters announce URLs only;
|
||||
it does not disable DHT or PEX.
|
||||
@@ -70,4 +71,5 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar
|
||||
current explicit metadata path intentionally avoids unmapped child jobs.
|
||||
|
||||
The first implementation in this task was remote `.torrent` metadata intake;
|
||||
the follow-up implementation adds the former Tier 0 stall-timeout control.
|
||||
follow-up implementations add stall-timeout control and bounded peer
|
||||
diagnostics.
|
||||
|
||||
@@ -202,6 +202,31 @@ pub struct DownloadItem {
|
||||
pub torrent_stop_timeout: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentPeer {
|
||||
#[ts(type = "number")]
|
||||
pub download_speed: u64,
|
||||
#[ts(type = "number")]
|
||||
pub upload_speed: u64,
|
||||
pub seeder: bool,
|
||||
pub am_choking: bool,
|
||||
pub peer_choking: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
pub struct TorrentPeerDiagnostics {
|
||||
#[ts(type = "number")]
|
||||
pub total_peers: u32,
|
||||
#[ts(type = "number")]
|
||||
pub total_seeders: u32,
|
||||
pub peers: Vec<TorrentPeer>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "../../src/bindings/")]
|
||||
|
||||
@@ -6452,6 +6452,14 @@ async fn set_torrent_peer_options(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_torrent_peers(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
|
||||
state.queue_manager.get_aria2_torrent_peers(&id).await
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option<String> {
|
||||
let trimmed = limit.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -10884,7 +10892,7 @@ pub fn run() {
|
||||
authorize_keychain_access,
|
||||
acknowledge_pairing_token_change,
|
||||
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
||||
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, set_global_speed_limit, remove_download, get_download_primary_path,
|
||||
get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_queue_concurrency_limits, set_download_speed_limit, set_torrent_upload_limit, set_torrent_peer_options, get_torrent_peers, set_global_speed_limit, remove_download, get_download_primary_path,
|
||||
detach_download_for_reconfigure,
|
||||
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
||||
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
||||
|
||||
@@ -965,6 +965,72 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return redacted, bounded peer diagnostics for the current Torrent GID.
|
||||
/// The control lock and post-RPC mapping check prevent a late response from
|
||||
/// being attributed to a replaced or terminal lifecycle.
|
||||
pub async fn get_aria2_torrent_peers(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<crate::ipc::TorrentPeerDiagnostics, 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("download is not an active aria2 transfer".to_string());
|
||||
}
|
||||
let is_torrent = self
|
||||
.aria2_payloads
|
||||
.lock()
|
||||
.await
|
||||
.get(id)
|
||||
.is_some_and(|payload| payload.is_torrent);
|
||||
if !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 transfer has no gid".to_string())?;
|
||||
let expected_mapping = self
|
||||
.aria2_gid_mapping(&gid)
|
||||
.ok_or_else(|| "active Torrent transfer 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 transfer has a stale control epoch".to_string());
|
||||
}
|
||||
|
||||
let state = self.app_handle.state::<crate::AppState>();
|
||||
let result = 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 diagnostics = parse_torrent_peer_diagnostics(result)?;
|
||||
|
||||
let still_current = self.is_registered(id).await
|
||||
&& matches!(self.active_kind(id).await, Some(TaskKind::Aria2))
|
||||
&& self
|
||||
.is_aria2_control_epoch_current(id, expected_mapping.epoch)
|
||||
.await
|
||||
&& self.is_current_aria2_gid_mapping(&gid, &expected_mapping)
|
||||
&& self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str());
|
||||
if !still_current {
|
||||
return Err("Torrent lifecycle changed while reading peer diagnostics".to_string());
|
||||
}
|
||||
|
||||
Ok(diagnostics)
|
||||
}
|
||||
|
||||
/// Pop the next task, or None if empty.
|
||||
pub async fn pop_front(&self) -> Option<QueuedTask> {
|
||||
self.pending.lock().await.pop_front()
|
||||
@@ -3050,6 +3116,8 @@ const ARIA2_DEFAULT_TORRENT_MAX_PEERS: u32 = 55;
|
||||
const ARIA2_DEFAULT_TORRENT_PEER_SPEED_LIMIT: &str = "50K";
|
||||
const MAX_TORRENT_MAX_PEERS: u32 = 1000;
|
||||
pub(crate) const MAX_TORRENT_STOP_TIMEOUT: u32 = 7 * 24 * 60 * 60;
|
||||
pub(crate) const MAX_TORRENT_PEER_DIAGNOSTICS: usize = 128;
|
||||
const MAX_TORRENT_PEER_RESPONSE: usize = 4096;
|
||||
const MAX_TORRENT_TRACKERS: usize = 64;
|
||||
const MAX_TORRENT_TRACKER_BYTES: usize = 16 * 1024;
|
||||
|
||||
@@ -3123,6 +3191,77 @@ pub(crate) fn normalize_torrent_stop_timeout(value: Option<u32>) -> Result<Optio
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn aria2_peer_number(value: Option<&serde_json::Value>) -> u64 {
|
||||
match value {
|
||||
Some(serde_json::Value::String(value)) => value.parse().unwrap_or_default(),
|
||||
Some(serde_json::Value::Number(value)) => value.as_u64().unwrap_or_default(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn aria2_peer_bool(value: Option<&serde_json::Value>) -> bool {
|
||||
match value {
|
||||
Some(serde_json::Value::Bool(value)) => *value,
|
||||
Some(serde_json::Value::String(value)) => {
|
||||
value.eq_ignore_ascii_case("true") || value == "1"
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_torrent_peer_diagnostics(
|
||||
result: serde_json::Value,
|
||||
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
|
||||
let peers = result
|
||||
.as_array()
|
||||
.ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?;
|
||||
if peers.len() > MAX_TORRENT_PEER_RESPONSE {
|
||||
return Err("aria2.getPeers returned too many peers".to_string());
|
||||
}
|
||||
if peers.iter().any(|peer| !peer.is_object()) {
|
||||
return Err("aria2.getPeers returned malformed peer data".to_string());
|
||||
}
|
||||
let total_peers = u32::try_from(peers.len()).unwrap_or(u32::MAX);
|
||||
let mut total_seeders = 0u32;
|
||||
let mut sanitized = Vec::with_capacity(peers.len().min(MAX_TORRENT_PEER_DIAGNOSTICS));
|
||||
|
||||
for peer in peers.iter().take(MAX_TORRENT_PEER_DIAGNOSTICS) {
|
||||
let peer = peer
|
||||
.as_object()
|
||||
.ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?;
|
||||
let seeder = aria2_peer_bool(peer.get("seeder"));
|
||||
if seeder {
|
||||
total_seeders = total_seeders.saturating_add(1);
|
||||
}
|
||||
sanitized.push(crate::ipc::TorrentPeer {
|
||||
download_speed: aria2_peer_number(peer.get("downloadSpeed")),
|
||||
upload_speed: aria2_peer_number(peer.get("uploadSpeed")),
|
||||
seeder,
|
||||
am_choking: aria2_peer_bool(peer.get("amChoking")),
|
||||
peer_choking: aria2_peer_bool(peer.get("peerChoking")),
|
||||
});
|
||||
}
|
||||
|
||||
// Count seeders beyond the display cap without retaining any identifying
|
||||
// peer data. The response is bounded by Aria2's per-Torrent peer limit,
|
||||
// while the UI receives at most MAX_TORRENT_PEER_DIAGNOSTICS rows.
|
||||
for peer in peers.iter().skip(MAX_TORRENT_PEER_DIAGNOSTICS) {
|
||||
let peer = peer
|
||||
.as_object()
|
||||
.ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?;
|
||||
if aria2_peer_bool(peer.get("seeder")) {
|
||||
total_seeders = total_seeders.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(crate::ipc::TorrentPeerDiagnostics {
|
||||
total_peers,
|
||||
total_seeders,
|
||||
peers: sanitized,
|
||||
truncated: peers.len() > MAX_TORRENT_PEER_DIAGNOSTICS,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_torrent_trackers(value: Option<&str>) -> Result<Option<String>, String> {
|
||||
let Some(raw) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
@@ -4210,6 +4349,57 @@ mod tests {
|
||||
assert!(!options.contains_key("bt-stop-timeout"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torrent_peer_diagnostics_are_redacted_and_bounded() {
|
||||
let mut result = vec![serde_json::json!({
|
||||
"peerId": "secret-peer-id",
|
||||
"ip": "192.0.2.10",
|
||||
"port": "6881",
|
||||
"bitfield": "ffffffff",
|
||||
"downloadSpeed": "10602",
|
||||
"uploadSpeed": "6890",
|
||||
"seeder": "true",
|
||||
"amChoking": "false",
|
||||
"peerChoking": "true"
|
||||
})];
|
||||
result.extend((1..MAX_TORRENT_PEER_DIAGNOSTICS + 2).map(|index| {
|
||||
serde_json::json!({
|
||||
"peerId": format!("peer-{index}"),
|
||||
"ip": format!("192.0.2.{index}"),
|
||||
"downloadSpeed": index.to_string(),
|
||||
"uploadSpeed": 0,
|
||||
"seeder": index == MAX_TORRENT_PEER_DIAGNOSTICS + 1,
|
||||
"amChoking": false,
|
||||
"peerChoking": false
|
||||
})
|
||||
}));
|
||||
|
||||
let diagnostics = parse_torrent_peer_diagnostics(serde_json::Value::Array(result)).unwrap();
|
||||
assert_eq!(diagnostics.total_peers, (MAX_TORRENT_PEER_DIAGNOSTICS + 2) as u32);
|
||||
assert_eq!(diagnostics.total_seeders, 2);
|
||||
assert_eq!(diagnostics.peers.len(), MAX_TORRENT_PEER_DIAGNOSTICS);
|
||||
assert!(diagnostics.truncated);
|
||||
let serialized = serde_json::to_string(&diagnostics).unwrap();
|
||||
assert!(!serialized.contains("peerId"));
|
||||
assert!(!serialized.contains("192.0.2."));
|
||||
assert!(!serialized.contains("bitfield"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torrent_peer_diagnostics_reject_non_array_results() {
|
||||
let error = parse_torrent_peer_diagnostics(serde_json::json!({"peers": []})).unwrap_err();
|
||||
assert!(error.contains("non-array"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torrent_peer_diagnostics_reject_malformed_peer_entries() {
|
||||
let error = parse_torrent_peer_diagnostics(serde_json::json!([{
|
||||
"seeder": true
|
||||
}, "not-a-peer"]))
|
||||
.unwrap_err();
|
||||
assert!(error.contains("malformed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enqueue_item_carries_torrent_trackers_into_the_spawn_payload() {
|
||||
let item: EnqueueItem = serde_json::from_value(serde_json::json!({
|
||||
|
||||
@@ -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 TorrentPeer = { downloadSpeed: number, uploadSpeed: number, seeder: boolean, amChoking: boolean, peerChoking: boolean, };
|
||||
@@ -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 { TorrentPeer } from "./TorrentPeer";
|
||||
|
||||
export type TorrentPeerDiagnostics = { totalPeers: number, totalSeeders: number, peers: Array<TorrentPeer>, truncated: boolean, };
|
||||
@@ -3,6 +3,8 @@ import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadProgressStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { resolveCategoryDestination } from '../utils/downloadLocations';
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
} from '../utils/downloadActions';
|
||||
import {
|
||||
downloadProgressColorClass,
|
||||
formatDownloadBytes,
|
||||
formatDownloadTotal,
|
||||
resolveDownloadSizeDisplay
|
||||
} from '../utils/downloadProgress';
|
||||
@@ -36,6 +39,12 @@ const formatLastTry = (
|
||||
});
|
||||
};
|
||||
|
||||
const isPeerDiagnosticsStatus = (status: string): boolean =>
|
||||
['downloading', 'seeding', 'retrying'].includes(status);
|
||||
|
||||
const formatPeerSpeed = (bytesPerSecond: number): string =>
|
||||
`${formatDownloadBytes(bytesPerSecond)}/s`;
|
||||
|
||||
export const PropertiesModal = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const categoryLabel = (category: string) => {
|
||||
@@ -80,6 +89,9 @@ export const PropertiesModal = () => {
|
||||
const [torrentCheckIntegrity, setTorrentCheckIntegrity] = useState(false);
|
||||
const [torrentTrackers, setTorrentTrackers] = useState('');
|
||||
const [torrentStopTimeout, setTorrentStopTimeout] = useState('0');
|
||||
const [torrentPeerDiagnostics, setTorrentPeerDiagnostics] = useState<TorrentPeerDiagnostics | null>(null);
|
||||
const [torrentPeerDiagnosticsError, setTorrentPeerDiagnosticsError] = useState(false);
|
||||
const [isTorrentPeerDiagnosticsPending, setIsTorrentPeerDiagnosticsPending] = useState(false);
|
||||
const [isLiveSpeedLimitPending, setIsLiveSpeedLimitPending] = useState(false);
|
||||
const [isLiveTorrentUploadLimitPending, setIsLiveTorrentUploadLimitPending] = useState(false);
|
||||
const [isLiveTorrentPeerOptionsPending, setIsLiveTorrentPeerOptionsPending] = useState(false);
|
||||
@@ -99,6 +111,7 @@ export const PropertiesModal = () => {
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [isPauseResumePending, setIsPauseResumePending] = useState(false);
|
||||
const actionRequestRef = useRef(0);
|
||||
const peerDiagnosticsRequestRef = useRef(0);
|
||||
const modalRef = useModalFocus(Boolean(selectedPropertiesDownloadId && item));
|
||||
|
||||
useEffect(() => {
|
||||
@@ -108,6 +121,10 @@ export const PropertiesModal = () => {
|
||||
setIsLiveSpeedLimitPending(false);
|
||||
setIsLiveTorrentUploadLimitPending(false);
|
||||
setIsLiveTorrentPeerOptionsPending(false);
|
||||
peerDiagnosticsRequestRef.current += 1;
|
||||
setTorrentPeerDiagnostics(null);
|
||||
setTorrentPeerDiagnosticsError(false);
|
||||
setIsTorrentPeerDiagnosticsPending(false);
|
||||
}, [selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -191,6 +208,13 @@ export const PropertiesModal = () => {
|
||||
setLiveTorrentUploadLimitValue(activeLimit && activeLimit !== '0' ? activeLimit : '');
|
||||
}, [item?.torrentUploadLimit, selectedPropertiesDownloadId]);
|
||||
|
||||
useEffect(() => {
|
||||
peerDiagnosticsRequestRef.current += 1;
|
||||
setTorrentPeerDiagnostics(null);
|
||||
setTorrentPeerDiagnosticsError(false);
|
||||
setIsTorrentPeerDiagnosticsPending(false);
|
||||
}, [item?.id, item?.isTorrent, item?.lastTry, item?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
setLiveTorrentMaxPeersValue(
|
||||
item?.torrentMaxPeers === undefined ? '' : String(item.torrentMaxPeers)
|
||||
@@ -243,6 +267,46 @@ export const PropertiesModal = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshTorrentPeers = async () => {
|
||||
if (
|
||||
isTorrentPeerDiagnosticsPending
|
||||
|| !item.isTorrent
|
||||
|| !isPeerDiagnosticsStatus(item.status)
|
||||
) return;
|
||||
|
||||
const requestId = ++peerDiagnosticsRequestRef.current;
|
||||
const propertiesDownloadId = item.id;
|
||||
setIsTorrentPeerDiagnosticsPending(true);
|
||||
setTorrentPeerDiagnosticsError(false);
|
||||
try {
|
||||
const diagnostics = await invoke('get_torrent_peers', { id: propertiesDownloadId });
|
||||
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
|
||||
if (
|
||||
requestId === peerDiagnosticsRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
&& currentItem?.isTorrent
|
||||
&& isPeerDiagnosticsStatus(currentItem.status)
|
||||
) {
|
||||
setTorrentPeerDiagnostics(diagnostics);
|
||||
}
|
||||
} catch {
|
||||
const currentItem = useDownloadStore.getState().downloads.find(download => download.id === propertiesDownloadId);
|
||||
if (
|
||||
requestId === peerDiagnosticsRequestRef.current
|
||||
&& useDownloadStore.getState().selectedPropertiesDownloadId === propertiesDownloadId
|
||||
&& currentItem?.isTorrent
|
||||
&& isPeerDiagnosticsStatus(currentItem.status)
|
||||
) {
|
||||
setTorrentPeerDiagnosticsError(true);
|
||||
setTorrentPeerDiagnostics(null);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === peerDiagnosticsRequestRef.current) {
|
||||
setIsTorrentPeerDiagnosticsPending(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!url.trim()) {
|
||||
setErrorMessage(t($ => $.properties.enterValidUrl));
|
||||
@@ -459,6 +523,7 @@ export const PropertiesModal = () => {
|
||||
const liveSpeedLimitUnavailable = item.isMedia && ['downloading', 'processing', 'retrying'].includes(item.status);
|
||||
const liveTorrentUploadLimitAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status);
|
||||
const liveTorrentPeerOptionsAvailable = item.isTorrent && ['downloading', 'seeding', 'retrying'].includes(item.status);
|
||||
const torrentPeerDiagnosticsAvailable = item.isTorrent && isPeerDiagnosticsStatus(item.status);
|
||||
const configuredConnections = resolveDownloadConnections(item.connections, perServerConnections);
|
||||
const observedConnectionTotal = Math.max(
|
||||
1,
|
||||
@@ -725,6 +790,80 @@ export const PropertiesModal = () => {
|
||||
<div className="col-start-2 text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentPeerOptionsSavedHint)}
|
||||
</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.torrentPeerDiagnostics)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRefreshTorrentPeers()}
|
||||
disabled={!torrentPeerDiagnosticsAvailable || isTorrentPeerDiagnosticsPending}
|
||||
className="app-button px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
{isTorrentPeerDiagnosticsPending
|
||||
? t($ => $.properties.torrentPeerDiagnosticsLoading)
|
||||
: t($ => $.properties.torrentPeerDiagnosticsRefresh)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentPeerDiagnosticsHint)}
|
||||
</p>
|
||||
{!torrentPeerDiagnosticsAvailable && (
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentPeerDiagnosticsUnavailable)}
|
||||
</p>
|
||||
)}
|
||||
{torrentPeerDiagnosticsError && (
|
||||
<p className="text-[11px] text-red-400">
|
||||
{t($ => $.properties.torrentPeerDiagnosticsFailed)}
|
||||
</p>
|
||||
)}
|
||||
{torrentPeerDiagnostics && (
|
||||
<>
|
||||
<div className="text-[11px] font-medium text-text-primary">
|
||||
{t($ => $.properties.torrentPeerCount, {
|
||||
total: torrentPeerDiagnostics.totalPeers,
|
||||
seeders: torrentPeerDiagnostics.totalSeeders
|
||||
})}
|
||||
</div>
|
||||
<div className="max-h-48 overflow-auto rounded border border-border-modal/60">
|
||||
<table className="w-full text-[10px]">
|
||||
<thead className="sticky top-0 bg-bg-input text-text-muted">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-start">#</th>
|
||||
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerDownload)}</th>
|
||||
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerUpload)}</th>
|
||||
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerSeeder)}</th>
|
||||
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerAmChoking)}</th>
|
||||
<th className="px-2 py-1 text-start">{t($ => $.properties.torrentPeerChoking)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{torrentPeerDiagnostics.peers.map((peer, index) => (
|
||||
<tr key={`${index}-${peer.downloadSpeed}-${peer.uploadSpeed}`} className="border-t border-border-modal/40 text-text-primary">
|
||||
<td className="px-2 py-1 font-mono">{index + 1}</td>
|
||||
<td className="px-2 py-1 font-mono">{formatPeerSpeed(peer.downloadSpeed)}</td>
|
||||
<td className="px-2 py-1 font-mono">{formatPeerSpeed(peer.uploadSpeed)}</td>
|
||||
<td className="px-2 py-1">{peer.seeder ? '✓' : '—'}</td>
|
||||
<td className="px-2 py-1">{peer.amChoking ? '✓' : '—'}</td>
|
||||
<td className="px-2 py-1">{peer.peerChoking ? '✓' : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{torrentPeerDiagnostics.truncated && (
|
||||
<p className="text-[11px] text-text-muted">
|
||||
{t($ => $.properties.torrentPeerShowing, {
|
||||
shown: torrentPeerDiagnostics.peers.length,
|
||||
total: torrentPeerDiagnostics.totalPeers
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<label className="text-xs text-text-muted text-right" htmlFor="torrent-trackers-properties">
|
||||
{t($ => $.properties.torrentTrackers)}
|
||||
</label>
|
||||
|
||||
@@ -245,6 +245,19 @@ const common = {
|
||||
torrentPeerSpeedLimit: 'Peer speed threshold',
|
||||
torrentMaxPeersInvalid: 'Torrent maximum peers must be an integer from 0 to 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Torrent peer speed threshold must be greater than zero',
|
||||
torrentPeerDiagnostics: 'Torrent peer diagnostics',
|
||||
torrentPeerDiagnosticsRefresh: 'Refresh',
|
||||
torrentPeerDiagnosticsLoading: 'Loading peer diagnostics…',
|
||||
torrentPeerDiagnosticsUnavailable: 'Peer diagnostics are available while this Torrent is active.',
|
||||
torrentPeerDiagnosticsFailed: 'Could not read Torrent peer diagnostics.',
|
||||
torrentPeerDiagnosticsHint: 'Speeds and connection flags only are shown; peer IPs, ports, IDs, and bitfields are not retained.',
|
||||
torrentPeerCount: '{{total}} peers · {{seeders}} seeders',
|
||||
torrentPeerDownload: 'Download',
|
||||
torrentPeerUpload: 'Upload',
|
||||
torrentPeerSeeder: 'Seeder',
|
||||
torrentPeerAmChoking: 'Firelink choking',
|
||||
torrentPeerChoking: 'Peer choking',
|
||||
torrentPeerShowing: 'Showing {{shown}} of {{total}} peers.',
|
||||
seconds: 'seconds',
|
||||
torrentStopTimeout: 'Stop stalled Torrent after',
|
||||
torrentStopTimeoutHint: 'Aria2 stops this Torrent after this many consecutive seconds at 0 B/s. 0 disables the policy; changes apply when the Torrent starts or retries.',
|
||||
|
||||
@@ -245,6 +245,19 @@ const fa = {
|
||||
torrentPeerSpeedLimit: 'آستانه سرعت همتا',
|
||||
torrentMaxPeersInvalid: 'حداکثر همتاهای تورنت باید عددی صحیح بین ۰ و ۱۰۰۰ باشد',
|
||||
torrentPeerSpeedLimitInvalid: 'آستانه سرعت همتای تورنت باید بیشتر از صفر باشد',
|
||||
torrentPeerDiagnostics: 'اطلاعات همتاهای تورنت',
|
||||
torrentPeerDiagnosticsRefresh: 'تازهسازی',
|
||||
torrentPeerDiagnosticsLoading: 'در حال دریافت اطلاعات همتاها…',
|
||||
torrentPeerDiagnosticsUnavailable: 'اطلاعات همتاها هنگام فعال بودن تورنت در دسترس است.',
|
||||
torrentPeerDiagnosticsFailed: 'خواندن اطلاعات همتاهای تورنت ممکن نیست.',
|
||||
torrentPeerDiagnosticsHint: 'فقط سرعت و وضعیت اتصال نمایش داده میشود؛ IP، پورت، شناسه و بیتفیلد همتاها ذخیره نمیشود.',
|
||||
torrentPeerCount: '{{total}} همتا · {{seeders}} سید',
|
||||
torrentPeerDownload: 'دریافت',
|
||||
torrentPeerUpload: 'آپلود',
|
||||
torrentPeerSeeder: 'سید',
|
||||
torrentPeerAmChoking: 'محدودسازی از طرف Firelink',
|
||||
torrentPeerChoking: 'محدودسازی از طرف همتا',
|
||||
torrentPeerShowing: 'نمایش {{shown}} همتا از {{total}} همتا.',
|
||||
seconds: 'ثانیه',
|
||||
torrentStopTimeout: 'توقف تورنتِ بدون سرعت پس از',
|
||||
torrentStopTimeoutHint: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف میکند. ۰ این سیاست را غیرفعال میکند؛ تغییرات هنگام شروع یا تلاش مجدد اعمال میشوند.',
|
||||
|
||||
@@ -245,6 +245,19 @@ const he = {
|
||||
torrentPeerSpeedLimit: 'סף מהירות עמיתים',
|
||||
torrentMaxPeersInvalid: 'מספר העמיתים המרבי חייב להיות מספר שלם בין 0 ל-1000',
|
||||
torrentPeerSpeedLimitInvalid: 'סף מהירות העמיתים חייב להיות גדול מאפס',
|
||||
torrentPeerDiagnostics: 'אבחון עמיתי טורנט',
|
||||
torrentPeerDiagnosticsRefresh: 'רענון',
|
||||
torrentPeerDiagnosticsLoading: 'טוען אבחון עמיתים…',
|
||||
torrentPeerDiagnosticsUnavailable: 'אבחון עמיתים זמין כשהטורנט פעיל.',
|
||||
torrentPeerDiagnosticsFailed: 'לא ניתן לקרוא את אבחון עמיתי הטורנט.',
|
||||
torrentPeerDiagnosticsHint: 'מוצגים רק מהירויות ודגלי חיבור; כתובות IP, יציאות, מזהים ושדות ביטים אינם נשמרים.',
|
||||
torrentPeerCount: '{{total}} עמיתים · {{seeders}} משתפים',
|
||||
torrentPeerDownload: 'הורדה',
|
||||
torrentPeerUpload: 'העלאה',
|
||||
torrentPeerSeeder: 'משתף',
|
||||
torrentPeerAmChoking: 'Firelink מגביל',
|
||||
torrentPeerChoking: 'העמית מגביל',
|
||||
torrentPeerShowing: 'מוצגים {{shown}} מתוך {{total}} עמיתים.',
|
||||
seconds: 'שניות',
|
||||
torrentStopTimeout: 'עצירת טורנט תקוע לאחר',
|
||||
torrentStopTimeoutHint: 'Aria2 יעצור את הטורנט לאחר מספר זה של שניות רצופות במהירות 0 B/s. 0 משבית את המדיניות; השינוי חל כשהטורנט מתחיל או מנסה שוב.',
|
||||
|
||||
@@ -245,6 +245,19 @@ const ru = {
|
||||
torrentPeerSpeedLimit: 'Порог скорости пиров',
|
||||
torrentMaxPeersInvalid: 'Максимум пиров должен быть целым числом от 0 до 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Порог скорости пиров должен быть больше нуля',
|
||||
torrentPeerDiagnostics: 'Диагностика пиров торрента',
|
||||
torrentPeerDiagnosticsRefresh: 'Обновить',
|
||||
torrentPeerDiagnosticsLoading: 'Загрузка диагностики пиров…',
|
||||
torrentPeerDiagnosticsUnavailable: 'Диагностика пиров доступна, пока торрент активен.',
|
||||
torrentPeerDiagnosticsFailed: 'Не удалось получить диагностику пиров торрента.',
|
||||
torrentPeerDiagnosticsHint: 'Показываются только скорости и флаги соединения; IP-адреса, порты, идентификаторы и битовые поля не сохраняются.',
|
||||
torrentPeerCount: '{{total}} пиров · {{seeders}} сидеров',
|
||||
torrentPeerDownload: 'Загрузка',
|
||||
torrentPeerUpload: 'Отдача',
|
||||
torrentPeerSeeder: 'Сидер',
|
||||
torrentPeerAmChoking: 'Firelink ограничивает',
|
||||
torrentPeerChoking: 'Пир ограничивает',
|
||||
torrentPeerShowing: 'Показано {{shown}} из {{total}} пиров.',
|
||||
seconds: 'секунд',
|
||||
torrentStopTimeout: 'Останавливать неактивный торрент через',
|
||||
torrentStopTimeoutHint: 'Aria2 остановит этот торрент после указанного числа секунд подряд при скорости 0 Б/с. 0 отключает правило; изменения применяются при запуске или повторной попытке.',
|
||||
|
||||
@@ -245,6 +245,19 @@ const uk = {
|
||||
torrentPeerSpeedLimit: 'Поріг швидкості пірів',
|
||||
torrentMaxPeersInvalid: 'Максимум пірів має бути цілим числом від 0 до 1000',
|
||||
torrentPeerSpeedLimitInvalid: 'Поріг швидкості пірів має бути більшим за нуль',
|
||||
torrentPeerDiagnostics: 'Діагностика пірів торрента',
|
||||
torrentPeerDiagnosticsRefresh: 'Оновити',
|
||||
torrentPeerDiagnosticsLoading: 'Завантаження діагностики пірів…',
|
||||
torrentPeerDiagnosticsUnavailable: 'Діагностика пірів доступна, поки торрент активний.',
|
||||
torrentPeerDiagnosticsFailed: 'Не вдалося отримати діагностику пірів торрента.',
|
||||
torrentPeerDiagnosticsHint: 'Показуються лише швидкості та прапорці з’єднання; IP-адреси, порти, ідентифікатори й бітові поля не зберігаються.',
|
||||
torrentPeerCount: '{{total}} пірів · {{seeders}} сідів',
|
||||
torrentPeerDownload: 'Завантаження',
|
||||
torrentPeerUpload: 'Віддача',
|
||||
torrentPeerSeeder: 'Сідер',
|
||||
torrentPeerAmChoking: 'Firelink обмежує',
|
||||
torrentPeerChoking: 'Пір обмежує',
|
||||
torrentPeerShowing: 'Показано {{shown}} із {{total}} пірів.',
|
||||
seconds: 'секунд',
|
||||
torrentStopTimeout: 'Зупиняти торрент без швидкості через',
|
||||
torrentStopTimeoutHint: 'Aria2 зупинить цей торрент після вказаної кількості секунд поспіль зі швидкістю 0 Б/с. 0 вимикає правило; зміни застосовуються під час запуску або повторної спроби.',
|
||||
|
||||
@@ -245,6 +245,19 @@ const zhCN = {
|
||||
torrentPeerSpeedLimit: '对等节点速度阈值',
|
||||
torrentMaxPeersInvalid: 'Torrent 最大对等节点数必须是 0 到 1000 之间的整数',
|
||||
torrentPeerSpeedLimitInvalid: '对等节点速度阈值必须大于零',
|
||||
torrentPeerDiagnostics: 'Torrent 对等节点诊断',
|
||||
torrentPeerDiagnosticsRefresh: '刷新',
|
||||
torrentPeerDiagnosticsLoading: '正在加载对等节点诊断…',
|
||||
torrentPeerDiagnosticsUnavailable: 'Torrent 活跃时可查看对等节点诊断。',
|
||||
torrentPeerDiagnosticsFailed: '无法读取 Torrent 对等节点诊断。',
|
||||
torrentPeerDiagnosticsHint: '仅显示速度和连接状态;不会保留对等节点 IP、端口、ID 或位域。',
|
||||
torrentPeerCount: '{{total}} 个节点 · {{seeders}} 个做种节点',
|
||||
torrentPeerDownload: '下载',
|
||||
torrentPeerUpload: '上传',
|
||||
torrentPeerSeeder: '做种',
|
||||
torrentPeerAmChoking: 'Firelink 限制中',
|
||||
torrentPeerChoking: '对等节点限制中',
|
||||
torrentPeerShowing: '显示 {{total}} 个节点中的 {{shown}} 个。',
|
||||
seconds: '秒',
|
||||
torrentStopTimeout: '在此时间后停止无速度 Torrent',
|
||||
torrentStopTimeoutHint: 'Aria2 会在速度连续为 0 B/s 达到此秒数后停止该 Torrent。0 表示禁用;更改会在 Torrent 启动或重试时应用。',
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
|
||||
import type { PlatformInfo } from './bindings/PlatformInfo';
|
||||
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
||||
import type { TorrentMetadata } from './bindings/TorrentMetadata';
|
||||
import type { TorrentPeerDiagnostics } from './bindings/TorrentPeerDiagnostics';
|
||||
|
||||
type CommandMap = {
|
||||
fetch_metadata: {
|
||||
@@ -75,6 +76,7 @@ type CommandMap = {
|
||||
args: { id: string; max_peers: number | null; peer_speed_limit: string | null };
|
||||
result: void;
|
||||
};
|
||||
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
|
||||
set_global_speed_limit: { args: { limit: string | null }; result: void };
|
||||
request_automation_permission: { args: undefined; result: void };
|
||||
check_automation_permission: { args: undefined; result: void };
|
||||
|
||||
Reference in New Issue
Block a user