diff --git a/TORRENT_FEATURES.md b/TORRENT_FEATURES.md index 495c6b3..eaedaa5 100644 --- a/TORRENT_FEATURES.md +++ b/TORRENT_FEATURES.md @@ -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. diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 0017236..e97a613 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -202,6 +202,31 @@ pub struct DownloadItem { pub torrent_stop_timeout: Option, } +#[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, + pub truncated: bool, +} + #[derive(Clone, Debug, Serialize, Deserialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../../src/bindings/")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4190977..115eb1c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { + state.queue_manager.get_aria2_torrent_peers(&id).await +} + pub(crate) fn normalize_speed_limit_for_aria2(limit: &str) -> Option { 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, diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 27f6f73..f8d1247 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -965,6 +965,72 @@ impl QueueManager { 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 { + 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::(); + 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 { 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) -> Result) -> 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 { + 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, 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!({ diff --git a/src/bindings/TorrentPeer.ts b/src/bindings/TorrentPeer.ts new file mode 100644 index 0000000..c275ab0 --- /dev/null +++ b/src/bindings/TorrentPeer.ts @@ -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, }; diff --git a/src/bindings/TorrentPeerDiagnostics.ts b/src/bindings/TorrentPeerDiagnostics.ts new file mode 100644 index 0000000..df286e6 --- /dev/null +++ b/src/bindings/TorrentPeerDiagnostics.ts @@ -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, truncated: boolean, }; diff --git a/src/components/PropertiesModal.tsx b/src/components/PropertiesModal.tsx index b2ca407..c32bbbd 100644 --- a/src/components/PropertiesModal.tsx +++ b/src/components/PropertiesModal.tsx @@ -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(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 = () => {
{t($ => $.properties.torrentPeerOptionsSavedHint)}
+
+
+
+ {t($ => $.properties.torrentPeerDiagnostics)} +
+ +
+

+ {t($ => $.properties.torrentPeerDiagnosticsHint)} +

+ {!torrentPeerDiagnosticsAvailable && ( +

+ {t($ => $.properties.torrentPeerDiagnosticsUnavailable)} +

+ )} + {torrentPeerDiagnosticsError && ( +

+ {t($ => $.properties.torrentPeerDiagnosticsFailed)} +

+ )} + {torrentPeerDiagnostics && ( + <> +
+ {t($ => $.properties.torrentPeerCount, { + total: torrentPeerDiagnostics.totalPeers, + seeders: torrentPeerDiagnostics.totalSeeders + })} +
+
+ + + + + + + + + + + + + {torrentPeerDiagnostics.peers.map((peer, index) => ( + + + + + + + + + ))} + +
#{t($ => $.properties.torrentPeerDownload)}{t($ => $.properties.torrentPeerUpload)}{t($ => $.properties.torrentPeerSeeder)}{t($ => $.properties.torrentPeerAmChoking)}{t($ => $.properties.torrentPeerChoking)}
{index + 1}{formatPeerSpeed(peer.downloadSpeed)}{formatPeerSpeed(peer.uploadSpeed)}{peer.seeder ? '✓' : '—'}{peer.amChoking ? '✓' : '—'}{peer.peerChoking ? '✓' : '—'}
+
+ {torrentPeerDiagnostics.truncated && ( +

+ {t($ => $.properties.torrentPeerShowing, { + shown: torrentPeerDiagnostics.peers.length, + total: torrentPeerDiagnostics.totalPeers + })} +

+ )} + + )} +
diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 052f5b4..e416b8e 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -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.', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index ac860fe..f8358be 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -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: 'آریا۲ پس از این تعداد ثانیه پیاپی با سرعت صفر، تورنت را متوقف می‌کند. ۰ این سیاست را غیرفعال می‌کند؛ تغییرات هنگام شروع یا تلاش مجدد اعمال می‌شوند.', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index faadb26..80e569c 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -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 משבית את המדיניות; השינוי חל כשהטורנט מתחיל או מנסה שוב.', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 40b5a99..6a886a9 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -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 отключает правило; изменения применяются при запуске или повторной попытке.', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 9f4a324..d738a9e 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -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 вимикає правило; зміни застосовуються під час запуску або повторної спроби.', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index 15481a2..697a080 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -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 启动或重试时应用。', diff --git a/src/ipc.ts b/src/ipc.ts index 71408e8..1b709c9 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -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 };