From ca32b772a2a2fe1e5a530f08fd6faefd0d02c996 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 15 Aug 2026 06:26:25 +0330 Subject: [PATCH] fix(torrent): use live peer telemetry in properties - source the Properties peer card from live Aria2 status counts - distinguish connected peers from unavailable peer details - remove redundant peer-summary IPC and harden count parsing - add responsive, accessible peer/seeder presentation and regressions --- src-tauri/src/ipc.rs | 10 -- src-tauri/src/lib.rs | 45 +++--- src-tauri/src/queue.rs | 58 ++------ src/bindings/TorrentPeerSummary.ts | 3 - src/components/PropertiesWindowApp.tsx | 142 ++++++++----------- src/i18n/catalogs/en.ts | 4 +- src/i18n/catalogs/fa.ts | 4 +- src/i18n/catalogs/he.ts | 4 +- src/i18n/catalogs/ru.ts | 4 +- src/i18n/catalogs/uk.ts | 4 +- src/i18n/catalogs/zh-CN.ts | 4 +- src/index.css | 22 +++ src/ipc.ts | 2 - src/propertiesBridge.test.ts | 7 +- src/propertiesBridge.ts | 15 +- src/utils/propertiesDiagnostics.test.ts | 8 ++ src/utils/propertiesDiagnostics.ts | 8 ++ src/utils/propertiesPeerSummary.test.ts | 23 --- src/utils/propertiesPeerSummary.ts | 26 ---- src/utils/propertiesPresentation.test.ts | 21 +-- src/utils/propertiesPresentation.ts | 20 +-- src/utils/propertiesTorrentLifecycle.test.ts | 12 ++ src/utils/propertiesTorrentLifecycle.ts | 10 ++ 23 files changed, 204 insertions(+), 252 deletions(-) delete mode 100644 src/bindings/TorrentPeerSummary.ts delete mode 100644 src/utils/propertiesPeerSummary.test.ts delete mode 100644 src/utils/propertiesPeerSummary.ts create mode 100644 src/utils/propertiesTorrentLifecycle.test.ts create mode 100644 src/utils/propertiesTorrentLifecycle.ts diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index 514ea6e..c55df92 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -346,16 +346,6 @@ pub struct TorrentPeerDiagnostics { pub truncated: bool, } -#[derive(Clone, Debug, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = "../../src/bindings/")] -pub struct TorrentPeerSummary { - #[ts(type = "number")] - pub total_peers: u32, - #[ts(type = "number")] - pub total_seeders: u32, -} - #[derive(Clone, Debug, Serialize, 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 b672efa..9272198 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7470,17 +7470,6 @@ async fn get_torrent_peers( state.queue_manager.get_aria2_torrent_peers(&id).await } -#[tauri::command] -async fn get_torrent_peer_summary( - caller: tauri::WebviewWindow, - properties: tauri::State<'_, properties_window::PropertiesWindowRegistry>, - state: tauri::State<'_, AppState>, - id: String, -) -> Result { - properties_window::ensure_properties_or_main(&caller, &properties, &id)?; - state.queue_manager.get_aria2_torrent_peer_summary(&id).await -} - #[tauri::command] async fn get_torrent_availability( caller: tauri::WebviewWindow, @@ -11194,7 +11183,7 @@ mod tests { observe_aria2_connections, observe_aria2_connections_with_epoch, Aria2ConnectionObservation, Aria2ConnectionSample, Aria2RecoveryReason, FrontendExitFlush, - aria2_active_connection_count, + aria2_active_connection_count, aria2_nonnegative_count, parse_media_playlist_metadata, normalize_media_connections, validate_enqueue_url, validate_enqueue_uris, validate_keychain_grant_request_id, @@ -11734,6 +11723,10 @@ mod tests { 0 ); assert_eq!(aria2_active_connection_count(&json!({})), 0); + assert_eq!(aria2_nonnegative_count(&json!({"numSeeders": "6"}), "numSeeders"), Some(6)); + assert_eq!(aria2_nonnegative_count(&json!({"numSeeders": 6}), "numSeeders"), Some(6)); + assert_eq!(aria2_nonnegative_count(&json!({"numSeeders": "-1"}), "numSeeders"), None); + assert_eq!(aria2_nonnegative_count(&json!({}), "numSeeders"), None); } #[test] @@ -13940,17 +13933,23 @@ struct Aria2ConnectionSample<'a> { now: Instant, } -fn aria2_active_connection_count(status_info: &serde_json::Value) -> i32 { +fn aria2_count_value(value: &serde_json::Value) -> Option { + value + .as_str() + .and_then(|value| value.parse::().ok()) + .or_else(|| value.as_i64()) + .and_then(|value| i32::try_from(value).ok()) +} + +fn aria2_nonnegative_count(status_info: &serde_json::Value, key: &str) -> Option { status_info - .get("connections") - .and_then(|value| { - value - .as_str() - .and_then(|value| value.parse::().ok()) - .or_else(|| value.as_i64().and_then(|value| i32::try_from(value).ok())) - }) + .get(key) + .and_then(aria2_count_value) .filter(|value| *value >= 0) - .unwrap_or(0) +} + +fn aria2_active_connection_count(status_info: &serde_json::Value) -> i32 { + aria2_nonnegative_count(status_info, "connections").unwrap_or(0) } const ARIA2_CONNECTION_RECOVERY_DELAY: Duration = Duration::from_secs(30); @@ -14968,7 +14967,7 @@ pub fn run() { let speed_bytes = status_info.get("downloadSpeed").and_then(|s| s.as_str()).unwrap_or("0").parse::().unwrap_or(0.0); let uploaded_bytes = status_info.get("uploadLength").and_then(|s| s.as_str()).and_then(|value| value.parse::().ok()); let upload_speed_bytes = status_info.get("uploadSpeed").and_then(|s| s.as_str()).and_then(|value| value.parse::().ok()); - let num_seeders = status_info.get("numSeeders").and_then(|s| s.as_str()).and_then(|value| value.parse::().ok()); + let num_seeders = aria2_nonnegative_count(status_info, "numSeeders"); let is_seeder = status_info.get("seeder").is_some_and(|value| { value.as_str() == Some("true") || value.as_bool() == Some(true) }); @@ -15558,7 +15557,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_frontend_exit, 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, get_torrent_peer_summary, get_torrent_availability, get_torrent_file_progress, get_torrent_piece_progress, get_torrent_file_selection, set_torrent_file_selection, get_torrent_details, get_torrent_magnet_link, export_torrent_metadata, move_torrent_data, cancel_torrent_move_data, verify_torrent_data, get_torrent_web_seeds, set_torrent_web_seeds, set_torrent_max_open_files, set_torrent_overall_upload_limit, set_global_speed_limit, remove_download, get_download_primary_path, + get_extension_server_port, set_extension_frontend_ready, ack_frontend_exit, 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, get_torrent_availability, get_torrent_file_progress, get_torrent_piece_progress, get_torrent_file_selection, set_torrent_file_selection, get_torrent_details, get_torrent_magnet_link, export_torrent_metadata, move_torrent_data, cancel_torrent_move_data, verify_torrent_data, get_torrent_web_seeds, set_torrent_web_seeds, set_torrent_max_open_files, set_torrent_overall_upload_limit, 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 88ef6ea..d2b8892 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -2734,19 +2734,6 @@ impl QueueManager { parse_torrent_peer_diagnostics(result) } - /// Return only aggregate peer/seeder counts for the current Torrent GID. - /// No peer addresses, IDs, bitfields, or transfer rates cross the IPC - /// boundary. - pub async fn get_aria2_torrent_peer_summary( - &self, - id: &str, - ) -> Result { - let result = self - .get_aria2_torrent_peer_result(id, "peer summary") - .await?; - parse_torrent_peer_summary(result) - } - /// 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. @@ -5955,9 +5942,14 @@ pub(crate) fn parse_torrent_availability( }) } -fn torrent_peer_summary_from_array( +struct TorrentPeerCounts { + total_peers: u32, + total_seeders: u32, +} + +fn torrent_peer_counts_from_array( peers: &[serde_json::Value], -) -> Result { +) -> Result { if peers.len() > MAX_TORRENT_PEER_RESPONSE { return Err("aria2.getPeers returned too many peers".to_string()); } @@ -5973,28 +5965,19 @@ fn torrent_peer_summary_from_array( total_seeders = total_seeders.saturating_add(1); } } - Ok(crate::ipc::TorrentPeerSummary { + Ok(TorrentPeerCounts { total_peers: u32::try_from(peers.len()).unwrap_or(u32::MAX), total_seeders, }) } -pub(crate) fn parse_torrent_peer_summary( - result: serde_json::Value, -) -> Result { - let peers = result - .as_array() - .ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?; - torrent_peer_summary_from_array(peers) -} - 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())?; - let summary = torrent_peer_summary_from_array(peers)?; + let summary = torrent_peer_counts_from_array(peers)?; let mut sanitized = Vec::with_capacity(peers.len().min(MAX_TORRENT_PEER_DIAGNOSTICS)); for peer in peers.iter().take(MAX_TORRENT_PEER_DIAGNOSTICS) { @@ -8344,28 +8327,10 @@ mod tests { assert!(!serialized.contains("bitfield")); } - #[test] - fn torrent_peer_summary_counts_all_seeders_without_returning_peer_data() { - let result = serde_json::json!([ - {"ip": "192.0.2.10", "seeder": "true", "bitfield": "secret"}, - {"ip": "192.0.2.11", "seeder": false}, - {"ip": "192.0.2.12", "seeder": true} - ]); - - let summary = parse_torrent_peer_summary(result).unwrap(); - assert_eq!(summary.total_peers, 3); - assert_eq!(summary.total_seeders, 2); - let serialized = serde_json::to_string(&summary).unwrap(); - 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")); - let summary_error = parse_torrent_peer_summary(serde_json::json!({"peers": []})).unwrap_err(); - assert!(summary_error.contains("non-array")); } #[test] @@ -8375,11 +8340,6 @@ mod tests { }, "not-a-peer"])) .unwrap_err(); assert!(error.contains("malformed")); - let summary_error = parse_torrent_peer_summary(serde_json::json!([{ - "seeder": true - }, "not-a-peer"])) - .unwrap_err(); - assert!(summary_error.contains("malformed")); } fn test_torrent_progress_metadata() -> Vec { diff --git a/src/bindings/TorrentPeerSummary.ts b/src/bindings/TorrentPeerSummary.ts deleted file mode 100644 index 5c2923f..0000000 --- a/src/bindings/TorrentPeerSummary.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type TorrentPeerSummary = { totalPeers: number, totalSeeders: number, }; diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index 36aa6b5..4371353 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -9,7 +9,6 @@ import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilit import type { TorrentDetails } from '../bindings/TorrentDetails'; import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot'; import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics'; -import type { TorrentPeerSummary } from '../bindings/TorrentPeerSummary'; import { invokeCommand as invoke } from '../ipc'; import { PROPERTIES_WINDOW_ACTION_RESULT, @@ -48,11 +47,12 @@ import { formatPropertiesDiagnosticCount, getPropertiesAvailabilityDiagnosticState, getPropertiesPeerDiagnosticState, + hasLiveTorrentPeerWithoutDetails, } from '../utils/propertiesDiagnostics'; import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl'; import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs'; import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation'; -import { isCurrentTorrentPeerSummary, isTorrentPeerSummaryStatus } from '../utils/propertiesPeerSummary'; +import { isTorrentLiveStatus } from '../utils/propertiesTorrentLifecycle'; import { WindowControls } from './WindowControls'; import { TORRENT_ENCRYPTION_POLICY_DISABLED, @@ -68,7 +68,7 @@ const SECRET_NAMES: SecretName[] = ['username', 'password', 'cookies', 'headers' const isTorrentDiagnosticsStatus = (status: string) => ['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused', 'completed'].includes(status); -const isTorrentPollingStatus = isTorrentPeerSummaryStatus; +const isTorrentPollingStatus = isTorrentLiveStatus; const isEditableStatus = (status: string) => !['downloading', 'processing', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'moving'].includes(status); @@ -215,7 +215,7 @@ export const PropertiesWindowApp = () => { const [pendingTorrentCommand, setPendingTorrentCommand] = useState<'magnet' | 'export' | 'move' | 'cancel' | null>(null); const [fileProgress, setFileProgress] = useState(null); const [peers, setPeers] = useState(null); - const [peerSummary, setPeerSummary] = useState(null); + const [peerDetailsUnavailable, setPeerDetailsUnavailable] = useState(false); const [availability, setAvailability] = useState(null); const [details, setDetails] = useState(null); const [diagnosticError, setDiagnosticError] = useState(''); @@ -269,13 +269,11 @@ export const PropertiesWindowApp = () => { const revealInFlightRef = useRef(false); const readyRetryTimerRef = useRef(undefined); const diagnosticsInFlightRef = useRef(new Set()); - const peerSummaryInFlightRef = useRef(new Set()); const snapshotRef = useRef(snapshot); const activeTabRef = useRef(activeTab); const downloadIdRef = useRef(downloadId); const fileProgressRef = useRef(fileProgress); const peersRef = useRef(peers); - const peerSummaryRef = useRef(peerSummary); const availabilityRef = useRef(availability); const detailsRef = useRef(details); const diagnosticAttemptsRef = useRef(new Set()); @@ -293,7 +291,6 @@ export const PropertiesWindowApp = () => { downloadIdRef.current = downloadId; fileProgressRef.current = fileProgress; peersRef.current = peers; - peerSummaryRef.current = peerSummary; availabilityRef.current = availability; detailsRef.current = details; @@ -513,16 +510,15 @@ export const PropertiesWindowApp = () => { if (isCurrent()) { if (peerResult.status === 'fulfilled') { setPeers(peerResult.value); - if (isTorrentPollingStatus(snapshotRef.current?.status ?? '')) { - peerSummaryRef.current = { - totalPeers: peerResult.value.totalPeers, - totalSeeders: peerResult.value.totalSeeders, - }; - setPeerSummary(peerSummaryRef.current); - } + setPeerDetailsUnavailable(hasLiveTorrentPeerWithoutDetails( + snapshotRef.current?.torrentConnectedPeers, + peerResult.value.totalPeers, + )); } else if (isExpectedPropertiesDiagnosticUnavailable(peerResult.reason)) { - peerSummaryRef.current = null; - setPeerSummary(null); + setPeerDetailsUnavailable(hasLiveTorrentPeerWithoutDetails( + snapshotRef.current?.torrentConnectedPeers, + 0, + )); } if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value); const peerOutcome = peerResult.status === 'fulfilled' @@ -588,40 +584,6 @@ export const PropertiesWindowApp = () => { } }, []); - const refreshPeerSummary = useCallback(async (id: string) => { - if (!isTorrentPollingStatus(snapshotRef.current?.status ?? '')) return; - const requestLifecycleEpoch = diagnosticLifecycleEpochRef.current; - const requestKey = `${id}:${requestLifecycleEpoch}`; - if (peerSummaryInFlightRef.current.has(requestKey)) return; - peerSummaryInFlightRef.current.add(requestKey); - const isCurrent = () => isCurrentTorrentPeerSummary({ - currentDownloadId: downloadIdRef.current, - requestDownloadId: id, - currentLifecycleEpoch: diagnosticLifecycleEpochRef.current, - requestLifecycleEpoch, - currentStatus: snapshotRef.current?.status ?? '', - }); - try { - const nextSummary = await invoke('get_torrent_peer_summary', { id }); - if (isCurrent()) { - peerSummaryRef.current = nextSummary; - setPeerSummary(nextSummary); - } - } catch (error) { - if (!isCurrent()) return; - // A GID replacement or terminal transition can invalidate an in-flight - // summary after Aria2 has already answered. The next fenced poll will - // acquire the new GID; do not turn that expected transition into a - // repeating Properties error. - if (isExpectedPropertiesDiagnosticUnavailable(error)) { - peerSummaryRef.current = null; - setPeerSummary(null); - } - } finally { - peerSummaryInFlightRef.current.delete(requestKey); - } - }, []); - useEffect(() => { let cancelled = false; let readyHeartbeatTimer: number | undefined; @@ -646,8 +608,6 @@ export const PropertiesWindowApp = () => { diagnosticLifecycleEpochRef.current += 1; diagnosticLifecycleKeyRef.current = ''; diagnosticAttemptsRef.current.clear(); - peerSummaryRef.current = null; - setPeerSummary(null); const lostAction = pendingActionRef.current; const lostDraftAction = lostAction === 'apply-properties' || lostAction === 'set-torrent-file-selection'; @@ -689,9 +649,8 @@ export const PropertiesWindowApp = () => { setDetails(null); setFileProgress(null); setPeers(null); + setPeerDetailsUnavailable(false); setAvailability(null); - peerSummaryRef.current = null; - setPeerSummary(null); setDiagnosticError(''); setDiagnosticsLoading(false); setDiagnosticsRefreshing(false); @@ -778,8 +737,6 @@ export const PropertiesWindowApp = () => { diagnosticLifecycleEpochRef.current += 1; diagnosticLifecycleKeyRef.current = ''; diagnosticAttemptsRef.current.clear(); - peerSummaryRef.current = null; - setPeerSummary(null); setSnapshot(null); draftTabRef.current = null; isDirtyRef.current = false; @@ -853,9 +810,8 @@ export const PropertiesWindowApp = () => { setDetails(null); setFileProgress(null); setPeers(null); + setPeerDetailsUnavailable(false); setAvailability(null); - peerSummaryRef.current = null; - setPeerSummary(null); setDiagnosticError(''); setDiagnosticsLoading(false); setDiagnosticsRefreshing(false); @@ -870,9 +826,8 @@ export const PropertiesWindowApp = () => { if (!isTorrentPollingStatus(snapshot.status)) { setFileProgress(null); setPeers(null); + setPeerDetailsUnavailable(false); setAvailability(null); - peerSummaryRef.current = null; - setPeerSummary(null); diagnosticLifecycleEpochRef.current += 1; diagnosticAttemptsRef.current.clear(); setDiagnosticPhase('idle'); @@ -880,21 +835,16 @@ export const PropertiesWindowApp = () => { setAvailabilityDiagnosticPhase('idle'); } void refreshDiagnostics(activeTab, downloadId); - if (isTorrentPollingStatus(snapshot.status) && activeTab !== 'peers') { - void refreshPeerSummary(downloadId); - } const shouldPollDiagnostics = ['files', 'peers'].includes(activeTab); - const shouldPollSummary = activeTab !== 'peers'; - if (!isTorrentPollingStatus(snapshot.status) || (!shouldPollDiagnostics && !shouldPollSummary)) return; + if (!isTorrentPollingStatus(snapshot.status) || !shouldPollDiagnostics) return; // Match the 1-second cadence of the normal Aria2 progress poll. The // diagnostics request itself is still single-flight, so a slow RPC cannot // create overlapping refreshes. const interval = window.setInterval(() => { if (shouldPollDiagnostics) void refreshDiagnostics(activeTab, downloadId); - if (shouldPollSummary) void refreshPeerSummary(downloadId); }, 1000); return () => window.clearInterval(interval); - }, [activeTab, downloadId, isTorrent, refreshDiagnostics, refreshPeerSummary, snapshot?.status]); + }, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot?.status]); useEffect(() => { let disposed = false; @@ -1194,18 +1144,40 @@ export const PropertiesWindowApp = () => { ? t($ => $.addDownloads.unknownSize) : `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`); const statusLabel = t($ => $.downloads.status[snapshot.status]); - const connectionPresentation = getPropertiesConnectionPresentation(snapshot, peerSummary); - const connectionLabel = connectionPresentation.labelKey === 'fragmentConcurrency' + const connectionPresentation = getPropertiesConnectionPresentation(snapshot); + const connectionHeaderLabel = connectionPresentation.labelKey === 'fragmentConcurrency' ? t($ => $.properties.fragmentConcurrency) - : connectionPresentation.labelKey === 'torrentConnectedPeers' - ? t($ => $.properties.torrentConnectedPeers) + : connectionPresentation.labelKey === 'torrentPeersSeeders' + ? t($ => $.properties.torrentPeersSeeders) : t($ => $.properties.connections); - const connectionValue = connectionPresentation.torrentPeerSummary - ? t($ => $.properties.torrentPeerSummary, { - total: connectionPresentation.torrentPeerSummary.totalPeers, - seeders: connectionPresentation.torrentPeerSummary.totalSeeders, - }) - : connectionPresentation.value; + const connectionControlLabel = snapshot.isTorrent === true + ? t($ => $.properties.torrentConnectedPeers) + : connectionHeaderLabel; + const connectionValue: ReactNode = connectionPresentation.torrentPeerCounts + ? (() => { + const peersValue = formatPropertiesDiagnosticCount( + connectionPresentation.torrentPeerCounts.connectedPeers ?? Number.NaN, + snapshot.appearance.locale, + ); + const seedersValue = formatPropertiesDiagnosticCount( + connectionPresentation.torrentPeerCounts.connectedSeeders ?? Number.NaN, + snapshot.appearance.locale, + ); + return $.properties.torrentConnectedPeerMetric, { + peers: peersValue, + seeders: seedersValue, + })} + > + {peersValue} + + {seedersValue} + ; + })() + : {connectionPresentation.value}; + const peerDetailsNotice = peerDetailsUnavailable + && (snapshot.torrentConnectedPeers ?? 0) > 0; const queuePlacement = formatPropertiesQueuePlacement( snapshot.queueName, snapshot.queuePosition, @@ -1297,7 +1269,7 @@ export const PropertiesWindowApp = () => {
{t($ => $.properties.size)}{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}
{t($ => $.properties.speed)}{snapshot.speed || '—'}
{t($ => $.properties.eta)}{snapshot.eta || '—'}
- {connectionPresentation.showHeaderMetric &&
{connectionLabel}{connectionValue}
} + {connectionPresentation.showHeaderMetric &&
{connectionHeaderLabel}{connectionValue}
} {isTorrent && <>
{t($ => $.properties.torrentUploaded)}{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}
{t($ => $.properties.torrentRatio)}{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}
@@ -1449,8 +1421,12 @@ export const PropertiesWindowApp = () => {
{t($ => $.properties.torrentPeerDiagnostics)} -

- {peers +

+ {peerDetailsNotice + ? t($ => $.properties.torrentPeerDetailsUnavailable, { + connected: formatPropertiesDiagnosticCount(snapshot.torrentConnectedPeers ?? 0, snapshot.appearance.locale), + }) + : peers ? t($ => $.properties.torrentPeerCount, { total: formatPropertiesDiagnosticCount(peers.totalPeers, snapshot.appearance.locale), seeders: formatPropertiesDiagnosticCount(peers.totalSeeders, snapshot.appearance.locale), @@ -1491,13 +1467,13 @@ export const PropertiesWindowApp = () => { { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} /> $.properties.fragmentConcurrencyHint) : undefined} className="max-w-md" >

- { setConnections(event.target.value); setDraftTab('transfer'); }} disabled={!editingEnabled} className="min-w-0 flex-1 accent-blue-500" aria-label={connectionLabel} /> + { setConnections(event.target.value); setDraftTab('transfer'); }} disabled={!editingEnabled} className="min-w-0 flex-1 accent-blue-500" aria-label={connectionControlLabel} /> {connections || '1'}
@@ -1655,7 +1631,7 @@ export const PropertiesWindowApp = () => { {snapshot.credentialsRequired === true &&

{t($ => $.properties.credentialsRequired)}

} {isSftp && }
-
{connectionLabel}

{connectionValue}

+
{connectionHeaderLabel}

{connectionValue}

{t($ => $.properties.speedCap)}

{snapshot.speedLimit || '—'}

{t($ => $.properties.username)}

{snapshot.hasUsername ? '✓' : '—'}

{t($ => $.properties.password)}

{snapshot.hasPassword ? '✓' : '—'}

diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 8aae257..aa51109 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -345,7 +345,6 @@ const common = { 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', - torrentPeerSummary: '{{total}} peers · {{seeders}} seeders', torrentPeerDownload: 'Download', torrentPeerUpload: 'Upload', torrentPeerSeeder: 'Seeder', @@ -358,6 +357,9 @@ const common = { torrentSeededDuration: 'Seeded', torrentSeedTimeHint: 'How long this Torrent may continue seeding after its files finish downloading. Leave blank to use the default.', torrentConnectedPeers: 'Peers', + torrentPeersSeeders: 'Peers / Seeders', + torrentConnectedPeerMetric: '{{peers}} connected peers / {{seeders}} connected seeders', + torrentPeerDetailsUnavailable: '{{connected}} connected peers reported, but peer details are not available yet.', torrentSeeders: 'Seeders', torrentUploadSpeed: 'Upload speed', seconds: 'seconds', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 470148a..3b074f8 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -345,7 +345,6 @@ const fa = { torrentWebSeedsRemove: 'حذف وب‌سید', torrentWebSeedsInvalid: 'هر ردیف وب‌سید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.', torrentPeerCount: '{{total}} همتا — {{seeders}} سید', - torrentPeerSummary: '{{total}} همتا · {{seeders}} سید', torrentPeerDownload: 'دریافت', torrentPeerUpload: 'آپلود', torrentPeerSeeder: 'سید', @@ -358,6 +357,9 @@ const fa = { torrentSeededDuration: 'مدت سید', torrentSeedTimeHint: 'مدتی که تورنت پس از تکمیل دانلود به سید ادامه می‌دهد. برای استفاده از پیش‌فرض خالی بگذارید.', torrentConnectedPeers: 'همتاها', + torrentPeersSeeders: 'همتاهای متصل / سیدهای متصل', + torrentConnectedPeerMetric: '{{peers}} همتای متصل / {{seeders}} سید متصل', + torrentPeerDetailsUnavailable: '{{connected}} همتای متصل گزارش شده، اما جزئیات همتاها هنوز در دسترس نیست.', torrentSeeders: 'سیدها', torrentUploadSpeed: 'سرعت آپلود', seconds: 'ثانیه', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index d659846..b2e8fde 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -345,7 +345,6 @@ const he = { torrentWebSeedsRemove: 'הסר זריעת Web', torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.', torrentPeerCount: '{{total}} עמיתים — {{seeders}} משתפים', - torrentPeerSummary: '{{total}} עמיתים · {{seeders}} משתפים', torrentPeerDownload: 'הורדה', torrentPeerUpload: 'העלאה', torrentPeerSeeder: 'משתף', @@ -358,6 +357,9 @@ const he = { torrentSeededDuration: 'משך שיתוף', torrentSeedTimeHint: 'משך הזמן שבו הטורנט ימשיך לשתף לאחר סיום ההורדה. השאר ריק כדי להשתמש בברירת המחדל.', torrentConnectedPeers: 'עמיתים', + torrentPeersSeeders: 'עמיתים / משתפים', + torrentConnectedPeerMetric: '{{peers}} עמיתים מחוברים / {{seeders}} משתפים מחוברים', + torrentPeerDetailsUnavailable: 'דווחו {{connected}} עמיתים מחוברים, אך פרטי העמיתים עדיין אינם זמינים.', torrentSeeders: 'משתפים', torrentUploadSpeed: 'מהירות העלאה', seconds: 'שניות', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index a32785b..bf4ba97 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -345,7 +345,6 @@ const ru = { torrentWebSeedsRemove: 'Удалить веб-сид', torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.', torrentPeerCount: '{{total}} пиров — {{seeders}} сидеров', - torrentPeerSummary: '{{total}} пиров · {{seeders}} сидеров', torrentPeerDownload: 'Загрузка', torrentPeerUpload: 'Отдача', torrentPeerSeeder: 'Сидер', @@ -358,6 +357,9 @@ const ru = { torrentSeededDuration: 'Время раздачи', torrentSeedTimeHint: 'Как долго Torrent продолжает раздачу после завершения загрузки. Оставьте пустым для значения по умолчанию.', torrentConnectedPeers: 'Пиры', + torrentPeersSeeders: 'Пиры / Сиды', + torrentConnectedPeerMetric: '{{peers}} подключённых пиров / {{seeders}} подключённых сидов', + torrentPeerDetailsUnavailable: 'Подключённых пиров: {{connected}}, но сведения о них пока недоступны.', torrentSeeders: 'Сиды', torrentUploadSpeed: 'Скорость отдачи', seconds: 'секунд', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index a6b2ff8..e4706f7 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -345,7 +345,6 @@ const uk = { torrentWebSeedsRemove: 'Видалити вебсід', torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.', torrentPeerCount: '{{total}} пірів — {{seeders}} сідів', - torrentPeerSummary: '{{total}} пірів · {{seeders}} сідів', torrentPeerDownload: 'Завантаження', torrentPeerUpload: 'Віддача', torrentPeerSeeder: 'Сідер', @@ -358,6 +357,9 @@ const uk = { torrentSeededDuration: 'Час роздачі', torrentSeedTimeHint: 'Як довго Torrent продовжує роздачу після завершення завантаження. Залиште порожнім для значення за замовчуванням.', torrentConnectedPeers: 'Піри', + torrentPeersSeeders: 'Піри / Сіди', + torrentConnectedPeerMetric: '{{peers}} підключених пірів / {{seeders}} підключених сідів', + torrentPeerDetailsUnavailable: 'Підключених пірів: {{connected}}, але відомості про них поки недоступні.', torrentSeeders: 'Сіди', torrentUploadSpeed: 'Швидкість віддачі', seconds: 'секунд', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index a0d20e7..bf75e4d 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -345,7 +345,6 @@ const zhCN = { torrentWebSeedsRemove: '移除 Web 做种', torrentWebSeedsInvalid: '每行 Web 做种都需要有效的 Torrent 文件和不含凭据或片段的 HTTP(S) 基础地址。', torrentPeerCount: '{{total}} 个节点 — {{seeders}} 个做种节点', - torrentPeerSummary: '{{total}} 个节点 · {{seeders}} 个做种节点', torrentPeerDownload: '下载', torrentPeerUpload: '上传', torrentPeerSeeder: '做种', @@ -358,6 +357,9 @@ const zhCN = { torrentSeededDuration: '做种时长', torrentSeedTimeHint: '文件下载完成后继续做种的时长。留空以使用默认值。', torrentConnectedPeers: '连接数', + torrentPeersSeeders: '节点 / 做种', + torrentConnectedPeerMetric: '{{peers}} 个已连接节点 / {{seeders}} 个已连接做种节点', + torrentPeerDetailsUnavailable: '检测到 {{connected}} 个已连接节点,但其详细信息暂时不可用。', torrentSeeders: '种子数', torrentUploadSpeed: '上传速度', seconds: '秒', diff --git a/src/index.css b/src/index.css index 75b6d1b..6232031 100644 --- a/src/index.css +++ b/src/index.css @@ -835,6 +835,16 @@ html[data-list-density="relaxed"] { white-space: nowrap; } + .properties-metric-card .properties-metric-label--wide { + overflow: visible; + font-size: 9px; + letter-spacing: 0.01em; + line-height: 1.15; + min-height: 20px; + text-overflow: clip; + white-space: normal; + } + .properties-metric-card strong { overflow: hidden; color: hsl(var(--text-primary)); @@ -845,6 +855,18 @@ html[data-list-density="relaxed"] { white-space: nowrap; } + .properties-metric-card .properties-torrent-peer-count { + display: inline-flex; + overflow: visible; + align-items: baseline; + gap: 1px; + text-overflow: clip; + } + + .properties-metric-card .properties-torrent-peer-count-primary { + color: hsl(var(--accent-color)); + } + .properties-window-destination { display: flex; min-width: 0; diff --git a/src/ipc.ts b/src/ipc.ts index 24ac9e1..8e31625 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -20,7 +20,6 @@ import type { PlatformInfo } from './bindings/PlatformInfo'; import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig'; import type { TorrentMetadata } from './bindings/TorrentMetadata'; import type { TorrentPeerDiagnostics } from './bindings/TorrentPeerDiagnostics'; -import type { TorrentPeerSummary } from './bindings/TorrentPeerSummary'; import type { TorrentFileProgressSnapshot } from './bindings/TorrentFileProgressSnapshot'; import type { TorrentPieceProgressSnapshot } from './bindings/TorrentPieceProgressSnapshot'; import type { TorrentWebSeed } from './bindings/TorrentWebSeed'; @@ -95,7 +94,6 @@ type CommandMap = { result: void; }; get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics }; - get_torrent_peer_summary: { args: { id: string }; result: TorrentPeerSummary }; get_torrent_file_progress: { args: { id: string }; result: TorrentFileProgressSnapshot }; get_torrent_piece_progress: { args: { id: string }; result: TorrentPieceProgressSnapshot }; get_torrent_file_selection: { args: { id: string }; result: TorrentFileSelectionSnapshot }; diff --git a/src/propertiesBridge.test.ts b/src/propertiesBridge.test.ts index e04643b..1ddde09 100644 --- a/src/propertiesBridge.test.ts +++ b/src/propertiesBridge.test.ts @@ -173,11 +173,11 @@ describe('Properties window bridge', () => { downloaded_bytes: 3, total_bytes: 4, total_is_estimate: false, - active_connections: 4, + active_connections: 0, requested_connections: 8, uploaded_bytes: 9, upload_speed: '1 MiB/s', - num_seeders: 6, + num_seeders: 0, torrent_seeded_seconds: 12, }, moveProgress: 0.5, @@ -193,7 +193,8 @@ describe('Properties window bridge', () => { totalIsEstimate: false, torrentUploadedBytes: 9, uploadSpeed: '1 MiB/s', - torrentSeeders: 6, + torrentConnectedPeers: 0, + torrentConnectedSeeders: 0, torrentSeededSeconds: 12, moveProgress: 0.5, }); diff --git a/src/propertiesBridge.ts b/src/propertiesBridge.ts index 96bc275..43235b1 100644 --- a/src/propertiesBridge.ts +++ b/src/propertiesBridge.ts @@ -180,7 +180,8 @@ export type PropertiesSnapshot = SafePropertiesFields & { activeConnections?: number; requestedConnections?: number; uploadSpeed?: string; - torrentSeeders?: number; + torrentConnectedPeers?: number; + torrentConnectedSeeders?: number; moveProgress?: number; hasPassword: boolean; hasCookies: boolean; @@ -425,9 +426,11 @@ const copyWithoutSecrets = ( ? { totalIsEstimate: live.progress.total_is_estimate } : {}), ...(live.progress.active_connections !== undefined - && item.isTorrent !== true - && item.isMedia !== true - ? { activeConnections: live.progress.active_connections } + ? item.isTorrent === true + ? { torrentConnectedPeers: live.progress.active_connections } + : item.isMedia !== true + ? { activeConnections: live.progress.active_connections } + : {} : {}), ...(item.isTorrent !== true && item.isMedia !== true @@ -440,8 +443,8 @@ const copyWithoutSecrets = ( ...(live.progress.upload_speed !== undefined ? { uploadSpeed: live.progress.upload_speed } : {}), - ...(live.progress.num_seeders !== undefined - ? { torrentSeeders: live.progress.num_seeders } + ...(live.progress.num_seeders !== undefined && item.isTorrent === true + ? { torrentConnectedSeeders: live.progress.num_seeders } : {}), ...(live.progress.torrent_seeded_seconds !== undefined ? { torrentSeededSeconds: live.progress.torrent_seeded_seconds } diff --git a/src/utils/propertiesDiagnostics.test.ts b/src/utils/propertiesDiagnostics.test.ts index 7f4b10f..89b8b7d 100644 --- a/src/utils/propertiesDiagnostics.test.ts +++ b/src/utils/propertiesDiagnostics.test.ts @@ -4,6 +4,7 @@ import { formatPropertiesDiagnosticCount, getPropertiesAvailabilityDiagnosticState, getPropertiesPeerDiagnosticState, + hasLiveTorrentPeerWithoutDetails, } from './propertiesDiagnostics'; const emptyPeerDiagnostics = { @@ -49,4 +50,11 @@ describe('Properties peer diagnostics presentation state', () => { expect(getPropertiesAvailabilityDiagnosticState(null, false, 'idle')).toBe('unavailable'); expect(getPropertiesAvailabilityDiagnosticState(null, false, 'error')).toBe('error'); }); + + it('distinguishes a live connection from an empty peer-detail snapshot', () => { + expect(hasLiveTorrentPeerWithoutDetails(1, 0)).toBe(true); + expect(hasLiveTorrentPeerWithoutDetails(0, 0)).toBe(false); + expect(hasLiveTorrentPeerWithoutDetails(undefined, 0)).toBe(false); + expect(hasLiveTorrentPeerWithoutDetails(2, 1)).toBe(false); + }); }); diff --git a/src/utils/propertiesDiagnostics.ts b/src/utils/propertiesDiagnostics.ts index 625323c..7266c63 100644 --- a/src/utils/propertiesDiagnostics.ts +++ b/src/utils/propertiesDiagnostics.ts @@ -10,6 +10,14 @@ export const formatPropertiesDiagnosticCount = (value: number, locale: string): return new Intl.NumberFormat(resolveAppLocale(locale)).format(value); }; +export const hasLiveTorrentPeerWithoutDetails = ( + connectedPeers: number | undefined, + detailedPeers: number, +): boolean => Number.isSafeInteger(connectedPeers) + && (connectedPeers ?? 0) > 0 + && Number.isSafeInteger(detailedPeers) + && detailedPeers === 0; + export const formatPropertiesAvailability = (availability: number, locale: string): string => { if (!Number.isFinite(availability) || availability < 0) return '—'; return new Intl.NumberFormat(resolveAppLocale(locale), { maximumFractionDigits: 2 }).format(availability); diff --git a/src/utils/propertiesPeerSummary.test.ts b/src/utils/propertiesPeerSummary.test.ts deleted file mode 100644 index 3767358..0000000 --- a/src/utils/propertiesPeerSummary.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { isCurrentTorrentPeerSummary, isTorrentPeerSummaryStatus } from './propertiesPeerSummary'; - -describe('Torrent peer summary lifecycle', () => { - it('accepts only the current active Torrent lifecycle', () => { - const request = { - currentDownloadId: 'torrent-1', - requestDownloadId: 'torrent-1', - currentLifecycleEpoch: 8, - requestLifecycleEpoch: 8, - currentStatus: 'downloading', - }; - expect(isCurrentTorrentPeerSummary(request)).toBe(true); - expect(isCurrentTorrentPeerSummary({ ...request, currentLifecycleEpoch: 9 })).toBe(false); - expect(isCurrentTorrentPeerSummary({ ...request, requestDownloadId: 'torrent-2' })).toBe(false); - }); - - it('stops live summary polling for paused and completed lifecycles', () => { - expect(isTorrentPeerSummaryStatus('paused')).toBe(false); - expect(isTorrentPeerSummaryStatus('completed')).toBe(false); - expect(isTorrentPeerSummaryStatus('seeding')).toBe(true); - }); -}); diff --git a/src/utils/propertiesPeerSummary.ts b/src/utils/propertiesPeerSummary.ts deleted file mode 100644 index ad4f2ae..0000000 --- a/src/utils/propertiesPeerSummary.ts +++ /dev/null @@ -1,26 +0,0 @@ -const TORRENT_PEER_SUMMARY_ACTIVE_STATUSES = [ - 'downloading', - 'verifying', - 'seeding', - 'waitingToSeed', - 'retrying', -] as const; - -export const isTorrentPeerSummaryStatus = (status: string): boolean => - (TORRENT_PEER_SUMMARY_ACTIVE_STATUSES as readonly string[]).includes(status); - -export const isCurrentTorrentPeerSummary = ({ - currentDownloadId, - requestDownloadId, - currentLifecycleEpoch, - requestLifecycleEpoch, - currentStatus, -}: { - currentDownloadId: string | null; - requestDownloadId: string; - currentLifecycleEpoch: number; - requestLifecycleEpoch: number; - currentStatus: string; -}): boolean => currentDownloadId === requestDownloadId - && currentLifecycleEpoch === requestLifecycleEpoch - && isTorrentPeerSummaryStatus(currentStatus); diff --git a/src/utils/propertiesPresentation.test.ts b/src/utils/propertiesPresentation.test.ts index 3d4710d..568d308 100644 --- a/src/utils/propertiesPresentation.test.ts +++ b/src/utils/propertiesPresentation.test.ts @@ -65,26 +65,29 @@ describe('Properties connection presentation', () => { })).toEqual({ kind: 'torrent', showHeaderMetric: true, - labelKey: 'torrentConnectedPeers', + labelKey: 'torrentPeersSeeders', value: '—', + torrentPeerCounts: { + connectedPeers: undefined, + connectedSeeders: undefined, + }, }); }); - it('exposes the explicit live peer and seeder summary values', () => { + it('uses live connected peer and seeder counts from the Properties snapshot', () => { expect(getPropertiesConnectionPresentation({ isMedia: false, isTorrent: true, - }, { - totalPeers: 41, - totalSeeders: 2, + torrentConnectedPeers: 10, + torrentConnectedSeeders: 2, })).toEqual({ kind: 'torrent', showHeaderMetric: true, - labelKey: 'torrentConnectedPeers', + labelKey: 'torrentPeersSeeders', value: '—', - torrentPeerSummary: { - totalPeers: 41, - totalSeeders: 2, + torrentPeerCounts: { + connectedPeers: 10, + connectedSeeders: 2, }, }); }); diff --git a/src/utils/propertiesPresentation.ts b/src/utils/propertiesPresentation.ts index fe23f44..346e74a 100644 --- a/src/utils/propertiesPresentation.ts +++ b/src/utils/propertiesPresentation.ts @@ -2,10 +2,10 @@ import type { PropertiesSnapshot } from '../propertiesBridge'; import { resolveDownloadFraction } from './downloadProgress'; export type PropertiesConnectionKind = 'media' | 'torrent' | 'aria2'; -export type PropertiesConnectionLabelKey = 'fragmentConcurrency' | 'torrentConnectedPeers' | 'connections'; -export type PropertiesTorrentPeerSummary = { - totalPeers: number; - totalSeeders: number; +export type PropertiesConnectionLabelKey = 'fragmentConcurrency' | 'torrentPeersSeeders' | 'connections'; +export type PropertiesTorrentPeerCounts = { + connectedPeers?: number; + connectedSeeders?: number; }; export type PropertiesConnectionPresentation = { @@ -13,7 +13,7 @@ export type PropertiesConnectionPresentation = { showHeaderMetric: boolean; labelKey: PropertiesConnectionLabelKey; value: string; - torrentPeerSummary?: PropertiesTorrentPeerSummary; + torrentPeerCounts?: PropertiesTorrentPeerCounts; }; const displayCount = (value: number | undefined): string => value == null ? '—' : String(value); @@ -25,8 +25,7 @@ export const getPropertiesProgress = ( : resolveDownloadFraction(snapshot); export const getPropertiesConnectionPresentation = ( - snapshot: Pick, - torrentPeerSummary?: PropertiesTorrentPeerSummary | null, + snapshot: Pick, ): PropertiesConnectionPresentation => { if (snapshot.isMedia === true) { return { @@ -41,9 +40,12 @@ export const getPropertiesConnectionPresentation = ( return { kind: 'torrent', showHeaderMetric: true, - labelKey: 'torrentConnectedPeers', + labelKey: 'torrentPeersSeeders', value: '—', - ...(torrentPeerSummary ? { torrentPeerSummary } : {}), + torrentPeerCounts: { + connectedPeers: snapshot.torrentConnectedPeers, + connectedSeeders: snapshot.torrentConnectedSeeders, + }, }; } diff --git a/src/utils/propertiesTorrentLifecycle.test.ts b/src/utils/propertiesTorrentLifecycle.test.ts new file mode 100644 index 0000000..4b3ccba --- /dev/null +++ b/src/utils/propertiesTorrentLifecycle.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { isTorrentLiveStatus } from './propertiesTorrentLifecycle'; + +describe('Torrent live lifecycle', () => { + it('identifies statuses with live Aria2 telemetry', () => { + expect(isTorrentLiveStatus('downloading')).toBe(true); + expect(isTorrentLiveStatus('retrying')).toBe(true); + expect(isTorrentLiveStatus('paused')).toBe(false); + expect(isTorrentLiveStatus('completed')).toBe(false); + expect(isTorrentLiveStatus('seeding')).toBe(true); + }); +}); diff --git a/src/utils/propertiesTorrentLifecycle.ts b/src/utils/propertiesTorrentLifecycle.ts new file mode 100644 index 0000000..16e5d89 --- /dev/null +++ b/src/utils/propertiesTorrentLifecycle.ts @@ -0,0 +1,10 @@ +const TORRENT_LIVE_STATUSES = [ + 'downloading', + 'verifying', + 'seeding', + 'waitingToSeed', + 'retrying', +] as const; + +export const isTorrentLiveStatus = (status: string): boolean => + (TORRENT_LIVE_STATUSES as readonly string[]).includes(status);