feat(torrents): add peer diagnostics

This commit is contained in:
NimBold
2026-08-02 01:04:04 +03:30
parent 2474d2c1cb
commit ab9f0507d0
14 changed files with 457 additions and 6 deletions
+7 -5
View File
@@ -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.
+25
View File
@@ -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/")]
+9 -1
View File
@@ -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,
+190
View 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!({
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type TorrentPeer = { downloadSpeed: number, uploadSpeed: number, seeder: boolean, amChoking: boolean, peerChoking: boolean, };
+4
View File
@@ -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, };
+139
View File
@@ -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>
+13
View File
@@ -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.',
+13
View File
@@ -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: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف می‌کند. ۰ این سیاست را غیرفعال می‌کند؛ تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.',
+13
View File
@@ -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 משבית את המדיניות; השינוי חל כשהטורנט מתחיל או מנסה שוב.',
+13
View File
@@ -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 отключает правило; изменения применяются при запуске или повторной попытке.',
+13
View File
@@ -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 вимикає правило; зміни застосовуються під час запуску або повторної спроби.',
+13
View File
@@ -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 启动或重试时应用。',
+2
View File
@@ -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 };