mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-19 15:46:17 +00:00
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
This commit is contained in:
@@ -346,16 +346,6 @@ pub struct TorrentPeerDiagnostics {
|
|||||||
pub truncated: bool,
|
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)]
|
#[derive(Clone, Debug, Serialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "../../src/bindings/")]
|
#[ts(export, export_to = "../../src/bindings/")]
|
||||||
|
|||||||
+22
-23
@@ -7470,17 +7470,6 @@ async fn get_torrent_peers(
|
|||||||
state.queue_manager.get_aria2_torrent_peers(&id).await
|
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<crate::ipc::TorrentPeerSummary, String> {
|
|
||||||
properties_window::ensure_properties_or_main(&caller, &properties, &id)?;
|
|
||||||
state.queue_manager.get_aria2_torrent_peer_summary(&id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn get_torrent_availability(
|
async fn get_torrent_availability(
|
||||||
caller: tauri::WebviewWindow,
|
caller: tauri::WebviewWindow,
|
||||||
@@ -11194,7 +11183,7 @@ mod tests {
|
|||||||
observe_aria2_connections, observe_aria2_connections_with_epoch,
|
observe_aria2_connections, observe_aria2_connections_with_epoch,
|
||||||
Aria2ConnectionObservation, Aria2ConnectionSample, Aria2RecoveryReason,
|
Aria2ConnectionObservation, Aria2ConnectionSample, Aria2RecoveryReason,
|
||||||
FrontendExitFlush,
|
FrontendExitFlush,
|
||||||
aria2_active_connection_count,
|
aria2_active_connection_count, aria2_nonnegative_count,
|
||||||
parse_media_playlist_metadata,
|
parse_media_playlist_metadata,
|
||||||
normalize_media_connections,
|
normalize_media_connections,
|
||||||
validate_enqueue_url, validate_enqueue_uris, validate_keychain_grant_request_id,
|
validate_enqueue_url, validate_enqueue_uris, validate_keychain_grant_request_id,
|
||||||
@@ -11734,6 +11723,10 @@ mod tests {
|
|||||||
0
|
0
|
||||||
);
|
);
|
||||||
assert_eq!(aria2_active_connection_count(&json!({})), 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]
|
#[test]
|
||||||
@@ -13940,17 +13933,23 @@ struct Aria2ConnectionSample<'a> {
|
|||||||
now: Instant,
|
now: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn aria2_active_connection_count(status_info: &serde_json::Value) -> i32 {
|
fn aria2_count_value(value: &serde_json::Value) -> Option<i32> {
|
||||||
|
value
|
||||||
|
.as_str()
|
||||||
|
.and_then(|value| value.parse::<i64>().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<i32> {
|
||||||
status_info
|
status_info
|
||||||
.get("connections")
|
.get(key)
|
||||||
.and_then(|value| {
|
.and_then(aria2_count_value)
|
||||||
value
|
|
||||||
.as_str()
|
|
||||||
.and_then(|value| value.parse::<i32>().ok())
|
|
||||||
.or_else(|| value.as_i64().and_then(|value| i32::try_from(value).ok()))
|
|
||||||
})
|
|
||||||
.filter(|value| *value >= 0)
|
.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);
|
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::<f64>().unwrap_or(0.0);
|
let speed_bytes = status_info.get("downloadSpeed").and_then(|s| s.as_str()).unwrap_or("0").parse::<f64>().unwrap_or(0.0);
|
||||||
let uploaded_bytes = status_info.get("uploadLength").and_then(|s| s.as_str()).and_then(|value| value.parse::<u64>().ok());
|
let uploaded_bytes = status_info.get("uploadLength").and_then(|s| s.as_str()).and_then(|value| value.parse::<u64>().ok());
|
||||||
let upload_speed_bytes = status_info.get("uploadSpeed").and_then(|s| s.as_str()).and_then(|value| value.parse::<f64>().ok());
|
let upload_speed_bytes = status_info.get("uploadSpeed").and_then(|s| s.as_str()).and_then(|value| value.parse::<f64>().ok());
|
||||||
let num_seeders = status_info.get("numSeeders").and_then(|s| s.as_str()).and_then(|value| value.parse::<i32>().ok());
|
let num_seeders = aria2_nonnegative_count(status_info, "numSeeders");
|
||||||
let is_seeder = status_info.get("seeder").is_some_and(|value| {
|
let is_seeder = status_info.get("seeder").is_some_and(|value| {
|
||||||
value.as_str() == Some("true") || value.as_bool() == Some(true)
|
value.as_str() == Some("true") || value.as_bool() == Some(true)
|
||||||
});
|
});
|
||||||
@@ -15558,7 +15557,7 @@ pub fn run() {
|
|||||||
authorize_keychain_access,
|
authorize_keychain_access,
|
||||||
acknowledge_pairing_token_change,
|
acknowledge_pairing_token_change,
|
||||||
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
|
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,
|
detach_download_for_reconfigure,
|
||||||
enqueue_download, enqueue_many, cancel_enqueue_generation, move_in_queue, move_many_in_queue, remove_from_queue, get_pending_order,
|
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,
|
commands::reveal_in_file_manager, commands::open_downloaded_file,
|
||||||
|
|||||||
+9
-49
@@ -2734,19 +2734,6 @@ impl<R: tauri::Runtime> QueueManager<R> {
|
|||||||
parse_torrent_peer_diagnostics(result)
|
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<crate::ipc::TorrentPeerSummary, String> {
|
|
||||||
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
|
/// Compute bounded, anonymized swarm availability for the current
|
||||||
/// Torrent lifecycle. The raw local/peer bitfields are consumed in native
|
/// Torrent lifecycle. The raw local/peer bitfields are consumed in native
|
||||||
/// memory and never returned to the frontend.
|
/// 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],
|
peers: &[serde_json::Value],
|
||||||
) -> Result<crate::ipc::TorrentPeerSummary, String> {
|
) -> Result<TorrentPeerCounts, String> {
|
||||||
if peers.len() > MAX_TORRENT_PEER_RESPONSE {
|
if peers.len() > MAX_TORRENT_PEER_RESPONSE {
|
||||||
return Err("aria2.getPeers returned too many peers".to_string());
|
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);
|
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_peers: u32::try_from(peers.len()).unwrap_or(u32::MAX),
|
||||||
total_seeders,
|
total_seeders,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parse_torrent_peer_summary(
|
|
||||||
result: serde_json::Value,
|
|
||||||
) -> Result<crate::ipc::TorrentPeerSummary, String> {
|
|
||||||
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(
|
pub(crate) fn parse_torrent_peer_diagnostics(
|
||||||
result: serde_json::Value,
|
result: serde_json::Value,
|
||||||
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
|
) -> Result<crate::ipc::TorrentPeerDiagnostics, String> {
|
||||||
let peers = result
|
let peers = result
|
||||||
.as_array()
|
.as_array()
|
||||||
.ok_or_else(|| "aria2.getPeers returned a non-array result".to_string())?;
|
.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));
|
let mut sanitized = Vec::with_capacity(peers.len().min(MAX_TORRENT_PEER_DIAGNOSTICS));
|
||||||
|
|
||||||
for peer in peers.iter().take(MAX_TORRENT_PEER_DIAGNOSTICS) {
|
for peer in peers.iter().take(MAX_TORRENT_PEER_DIAGNOSTICS) {
|
||||||
@@ -8344,28 +8327,10 @@ mod tests {
|
|||||||
assert!(!serialized.contains("bitfield"));
|
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]
|
#[test]
|
||||||
fn torrent_peer_diagnostics_reject_non_array_results() {
|
fn torrent_peer_diagnostics_reject_non_array_results() {
|
||||||
let error = parse_torrent_peer_diagnostics(serde_json::json!({"peers": []})).unwrap_err();
|
let error = parse_torrent_peer_diagnostics(serde_json::json!({"peers": []})).unwrap_err();
|
||||||
assert!(error.contains("non-array"));
|
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]
|
#[test]
|
||||||
@@ -8375,11 +8340,6 @@ mod tests {
|
|||||||
}, "not-a-peer"]))
|
}, "not-a-peer"]))
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(error.contains("malformed"));
|
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<crate::ipc::TorrentFile> {
|
fn test_torrent_progress_metadata() -> Vec<crate::ipc::TorrentFile> {
|
||||||
|
|||||||
@@ -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, };
|
|
||||||
@@ -9,7 +9,6 @@ import type { TorrentAvailabilitySnapshot } from '../bindings/TorrentAvailabilit
|
|||||||
import type { TorrentDetails } from '../bindings/TorrentDetails';
|
import type { TorrentDetails } from '../bindings/TorrentDetails';
|
||||||
import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot';
|
import type { TorrentFileProgressSnapshot } from '../bindings/TorrentFileProgressSnapshot';
|
||||||
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
|
import type { TorrentPeerDiagnostics } from '../bindings/TorrentPeerDiagnostics';
|
||||||
import type { TorrentPeerSummary } from '../bindings/TorrentPeerSummary';
|
|
||||||
import { invokeCommand as invoke } from '../ipc';
|
import { invokeCommand as invoke } from '../ipc';
|
||||||
import {
|
import {
|
||||||
PROPERTIES_WINDOW_ACTION_RESULT,
|
PROPERTIES_WINDOW_ACTION_RESULT,
|
||||||
@@ -48,11 +47,12 @@ import {
|
|||||||
formatPropertiesDiagnosticCount,
|
formatPropertiesDiagnosticCount,
|
||||||
getPropertiesAvailabilityDiagnosticState,
|
getPropertiesAvailabilityDiagnosticState,
|
||||||
getPropertiesPeerDiagnosticState,
|
getPropertiesPeerDiagnosticState,
|
||||||
|
hasLiveTorrentPeerWithoutDetails,
|
||||||
} from '../utils/propertiesDiagnostics';
|
} from '../utils/propertiesDiagnostics';
|
||||||
import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl';
|
import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl';
|
||||||
import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs';
|
import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs';
|
||||||
import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation';
|
import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation';
|
||||||
import { isCurrentTorrentPeerSummary, isTorrentPeerSummaryStatus } from '../utils/propertiesPeerSummary';
|
import { isTorrentLiveStatus } from '../utils/propertiesTorrentLifecycle';
|
||||||
import { WindowControls } from './WindowControls';
|
import { WindowControls } from './WindowControls';
|
||||||
import {
|
import {
|
||||||
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
TORRENT_ENCRYPTION_POLICY_DISABLED,
|
||||||
@@ -68,7 +68,7 @@ const SECRET_NAMES: SecretName[] = ['username', 'password', 'cookies', 'headers'
|
|||||||
const isTorrentDiagnosticsStatus = (status: string) =>
|
const isTorrentDiagnosticsStatus = (status: string) =>
|
||||||
['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused', 'completed'].includes(status);
|
['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);
|
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 [pendingTorrentCommand, setPendingTorrentCommand] = useState<'magnet' | 'export' | 'move' | 'cancel' | null>(null);
|
||||||
const [fileProgress, setFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
|
const [fileProgress, setFileProgress] = useState<TorrentFileProgressSnapshot | null>(null);
|
||||||
const [peers, setPeers] = useState<TorrentPeerDiagnostics | null>(null);
|
const [peers, setPeers] = useState<TorrentPeerDiagnostics | null>(null);
|
||||||
const [peerSummary, setPeerSummary] = useState<TorrentPeerSummary | null>(null);
|
const [peerDetailsUnavailable, setPeerDetailsUnavailable] = useState(false);
|
||||||
const [availability, setAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
|
const [availability, setAvailability] = useState<TorrentAvailabilitySnapshot | null>(null);
|
||||||
const [details, setDetails] = useState<TorrentDetails | null>(null);
|
const [details, setDetails] = useState<TorrentDetails | null>(null);
|
||||||
const [diagnosticError, setDiagnosticError] = useState('');
|
const [diagnosticError, setDiagnosticError] = useState('');
|
||||||
@@ -269,13 +269,11 @@ export const PropertiesWindowApp = () => {
|
|||||||
const revealInFlightRef = useRef(false);
|
const revealInFlightRef = useRef(false);
|
||||||
const readyRetryTimerRef = useRef<number | undefined>(undefined);
|
const readyRetryTimerRef = useRef<number | undefined>(undefined);
|
||||||
const diagnosticsInFlightRef = useRef(new Set<string>());
|
const diagnosticsInFlightRef = useRef(new Set<string>());
|
||||||
const peerSummaryInFlightRef = useRef(new Set<string>());
|
|
||||||
const snapshotRef = useRef(snapshot);
|
const snapshotRef = useRef(snapshot);
|
||||||
const activeTabRef = useRef(activeTab);
|
const activeTabRef = useRef(activeTab);
|
||||||
const downloadIdRef = useRef(downloadId);
|
const downloadIdRef = useRef(downloadId);
|
||||||
const fileProgressRef = useRef(fileProgress);
|
const fileProgressRef = useRef(fileProgress);
|
||||||
const peersRef = useRef(peers);
|
const peersRef = useRef(peers);
|
||||||
const peerSummaryRef = useRef(peerSummary);
|
|
||||||
const availabilityRef = useRef(availability);
|
const availabilityRef = useRef(availability);
|
||||||
const detailsRef = useRef(details);
|
const detailsRef = useRef(details);
|
||||||
const diagnosticAttemptsRef = useRef(new Set<string>());
|
const diagnosticAttemptsRef = useRef(new Set<string>());
|
||||||
@@ -293,7 +291,6 @@ export const PropertiesWindowApp = () => {
|
|||||||
downloadIdRef.current = downloadId;
|
downloadIdRef.current = downloadId;
|
||||||
fileProgressRef.current = fileProgress;
|
fileProgressRef.current = fileProgress;
|
||||||
peersRef.current = peers;
|
peersRef.current = peers;
|
||||||
peerSummaryRef.current = peerSummary;
|
|
||||||
availabilityRef.current = availability;
|
availabilityRef.current = availability;
|
||||||
detailsRef.current = details;
|
detailsRef.current = details;
|
||||||
|
|
||||||
@@ -513,16 +510,15 @@ export const PropertiesWindowApp = () => {
|
|||||||
if (isCurrent()) {
|
if (isCurrent()) {
|
||||||
if (peerResult.status === 'fulfilled') {
|
if (peerResult.status === 'fulfilled') {
|
||||||
setPeers(peerResult.value);
|
setPeers(peerResult.value);
|
||||||
if (isTorrentPollingStatus(snapshotRef.current?.status ?? '')) {
|
setPeerDetailsUnavailable(hasLiveTorrentPeerWithoutDetails(
|
||||||
peerSummaryRef.current = {
|
snapshotRef.current?.torrentConnectedPeers,
|
||||||
totalPeers: peerResult.value.totalPeers,
|
peerResult.value.totalPeers,
|
||||||
totalSeeders: peerResult.value.totalSeeders,
|
));
|
||||||
};
|
|
||||||
setPeerSummary(peerSummaryRef.current);
|
|
||||||
}
|
|
||||||
} else if (isExpectedPropertiesDiagnosticUnavailable(peerResult.reason)) {
|
} else if (isExpectedPropertiesDiagnosticUnavailable(peerResult.reason)) {
|
||||||
peerSummaryRef.current = null;
|
setPeerDetailsUnavailable(hasLiveTorrentPeerWithoutDetails(
|
||||||
setPeerSummary(null);
|
snapshotRef.current?.torrentConnectedPeers,
|
||||||
|
0,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value);
|
if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value);
|
||||||
const peerOutcome = peerResult.status === 'fulfilled'
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let readyHeartbeatTimer: number | undefined;
|
let readyHeartbeatTimer: number | undefined;
|
||||||
@@ -646,8 +608,6 @@ export const PropertiesWindowApp = () => {
|
|||||||
diagnosticLifecycleEpochRef.current += 1;
|
diagnosticLifecycleEpochRef.current += 1;
|
||||||
diagnosticLifecycleKeyRef.current = '';
|
diagnosticLifecycleKeyRef.current = '';
|
||||||
diagnosticAttemptsRef.current.clear();
|
diagnosticAttemptsRef.current.clear();
|
||||||
peerSummaryRef.current = null;
|
|
||||||
setPeerSummary(null);
|
|
||||||
const lostAction = pendingActionRef.current;
|
const lostAction = pendingActionRef.current;
|
||||||
const lostDraftAction = lostAction === 'apply-properties'
|
const lostDraftAction = lostAction === 'apply-properties'
|
||||||
|| lostAction === 'set-torrent-file-selection';
|
|| lostAction === 'set-torrent-file-selection';
|
||||||
@@ -689,9 +649,8 @@ export const PropertiesWindowApp = () => {
|
|||||||
setDetails(null);
|
setDetails(null);
|
||||||
setFileProgress(null);
|
setFileProgress(null);
|
||||||
setPeers(null);
|
setPeers(null);
|
||||||
|
setPeerDetailsUnavailable(false);
|
||||||
setAvailability(null);
|
setAvailability(null);
|
||||||
peerSummaryRef.current = null;
|
|
||||||
setPeerSummary(null);
|
|
||||||
setDiagnosticError('');
|
setDiagnosticError('');
|
||||||
setDiagnosticsLoading(false);
|
setDiagnosticsLoading(false);
|
||||||
setDiagnosticsRefreshing(false);
|
setDiagnosticsRefreshing(false);
|
||||||
@@ -778,8 +737,6 @@ export const PropertiesWindowApp = () => {
|
|||||||
diagnosticLifecycleEpochRef.current += 1;
|
diagnosticLifecycleEpochRef.current += 1;
|
||||||
diagnosticLifecycleKeyRef.current = '';
|
diagnosticLifecycleKeyRef.current = '';
|
||||||
diagnosticAttemptsRef.current.clear();
|
diagnosticAttemptsRef.current.clear();
|
||||||
peerSummaryRef.current = null;
|
|
||||||
setPeerSummary(null);
|
|
||||||
setSnapshot(null);
|
setSnapshot(null);
|
||||||
draftTabRef.current = null;
|
draftTabRef.current = null;
|
||||||
isDirtyRef.current = false;
|
isDirtyRef.current = false;
|
||||||
@@ -853,9 +810,8 @@ export const PropertiesWindowApp = () => {
|
|||||||
setDetails(null);
|
setDetails(null);
|
||||||
setFileProgress(null);
|
setFileProgress(null);
|
||||||
setPeers(null);
|
setPeers(null);
|
||||||
|
setPeerDetailsUnavailable(false);
|
||||||
setAvailability(null);
|
setAvailability(null);
|
||||||
peerSummaryRef.current = null;
|
|
||||||
setPeerSummary(null);
|
|
||||||
setDiagnosticError('');
|
setDiagnosticError('');
|
||||||
setDiagnosticsLoading(false);
|
setDiagnosticsLoading(false);
|
||||||
setDiagnosticsRefreshing(false);
|
setDiagnosticsRefreshing(false);
|
||||||
@@ -870,9 +826,8 @@ export const PropertiesWindowApp = () => {
|
|||||||
if (!isTorrentPollingStatus(snapshot.status)) {
|
if (!isTorrentPollingStatus(snapshot.status)) {
|
||||||
setFileProgress(null);
|
setFileProgress(null);
|
||||||
setPeers(null);
|
setPeers(null);
|
||||||
|
setPeerDetailsUnavailable(false);
|
||||||
setAvailability(null);
|
setAvailability(null);
|
||||||
peerSummaryRef.current = null;
|
|
||||||
setPeerSummary(null);
|
|
||||||
diagnosticLifecycleEpochRef.current += 1;
|
diagnosticLifecycleEpochRef.current += 1;
|
||||||
diagnosticAttemptsRef.current.clear();
|
diagnosticAttemptsRef.current.clear();
|
||||||
setDiagnosticPhase('idle');
|
setDiagnosticPhase('idle');
|
||||||
@@ -880,21 +835,16 @@ export const PropertiesWindowApp = () => {
|
|||||||
setAvailabilityDiagnosticPhase('idle');
|
setAvailabilityDiagnosticPhase('idle');
|
||||||
}
|
}
|
||||||
void refreshDiagnostics(activeTab, downloadId);
|
void refreshDiagnostics(activeTab, downloadId);
|
||||||
if (isTorrentPollingStatus(snapshot.status) && activeTab !== 'peers') {
|
|
||||||
void refreshPeerSummary(downloadId);
|
|
||||||
}
|
|
||||||
const shouldPollDiagnostics = ['files', 'peers'].includes(activeTab);
|
const shouldPollDiagnostics = ['files', 'peers'].includes(activeTab);
|
||||||
const shouldPollSummary = activeTab !== 'peers';
|
if (!isTorrentPollingStatus(snapshot.status) || !shouldPollDiagnostics) return;
|
||||||
if (!isTorrentPollingStatus(snapshot.status) || (!shouldPollDiagnostics && !shouldPollSummary)) return;
|
|
||||||
// Match the 1-second cadence of the normal Aria2 progress poll. The
|
// Match the 1-second cadence of the normal Aria2 progress poll. The
|
||||||
// diagnostics request itself is still single-flight, so a slow RPC cannot
|
// diagnostics request itself is still single-flight, so a slow RPC cannot
|
||||||
// create overlapping refreshes.
|
// create overlapping refreshes.
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
if (shouldPollDiagnostics) void refreshDiagnostics(activeTab, downloadId);
|
if (shouldPollDiagnostics) void refreshDiagnostics(activeTab, downloadId);
|
||||||
if (shouldPollSummary) void refreshPeerSummary(downloadId);
|
|
||||||
}, 1000);
|
}, 1000);
|
||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval);
|
||||||
}, [activeTab, downloadId, isTorrent, refreshDiagnostics, refreshPeerSummary, snapshot?.status]);
|
}, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot?.status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
@@ -1194,18 +1144,40 @@ export const PropertiesWindowApp = () => {
|
|||||||
? t($ => $.addDownloads.unknownSize)
|
? t($ => $.addDownloads.unknownSize)
|
||||||
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
|
: `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`);
|
||||||
const statusLabel = t($ => $.downloads.status[snapshot.status]);
|
const statusLabel = t($ => $.downloads.status[snapshot.status]);
|
||||||
const connectionPresentation = getPropertiesConnectionPresentation(snapshot, peerSummary);
|
const connectionPresentation = getPropertiesConnectionPresentation(snapshot);
|
||||||
const connectionLabel = connectionPresentation.labelKey === 'fragmentConcurrency'
|
const connectionHeaderLabel = connectionPresentation.labelKey === 'fragmentConcurrency'
|
||||||
? t($ => $.properties.fragmentConcurrency)
|
? t($ => $.properties.fragmentConcurrency)
|
||||||
: connectionPresentation.labelKey === 'torrentConnectedPeers'
|
: connectionPresentation.labelKey === 'torrentPeersSeeders'
|
||||||
? t($ => $.properties.torrentConnectedPeers)
|
? t($ => $.properties.torrentPeersSeeders)
|
||||||
: t($ => $.properties.connections);
|
: t($ => $.properties.connections);
|
||||||
const connectionValue = connectionPresentation.torrentPeerSummary
|
const connectionControlLabel = snapshot.isTorrent === true
|
||||||
? t($ => $.properties.torrentPeerSummary, {
|
? t($ => $.properties.torrentConnectedPeers)
|
||||||
total: connectionPresentation.torrentPeerSummary.totalPeers,
|
: connectionHeaderLabel;
|
||||||
seeders: connectionPresentation.torrentPeerSummary.totalSeeders,
|
const connectionValue: ReactNode = connectionPresentation.torrentPeerCounts
|
||||||
})
|
? (() => {
|
||||||
: connectionPresentation.value;
|
const peersValue = formatPropertiesDiagnosticCount(
|
||||||
|
connectionPresentation.torrentPeerCounts.connectedPeers ?? Number.NaN,
|
||||||
|
snapshot.appearance.locale,
|
||||||
|
);
|
||||||
|
const seedersValue = formatPropertiesDiagnosticCount(
|
||||||
|
connectionPresentation.torrentPeerCounts.connectedSeeders ?? Number.NaN,
|
||||||
|
snapshot.appearance.locale,
|
||||||
|
);
|
||||||
|
return <strong
|
||||||
|
className="properties-torrent-peer-count"
|
||||||
|
aria-label={t($ => $.properties.torrentConnectedPeerMetric, {
|
||||||
|
peers: peersValue,
|
||||||
|
seeders: seedersValue,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span className="properties-torrent-peer-count-primary">{peersValue}</span>
|
||||||
|
<span aria-hidden="true"> / </span>
|
||||||
|
<span>{seedersValue}</span>
|
||||||
|
</strong>;
|
||||||
|
})()
|
||||||
|
: <strong>{connectionPresentation.value}</strong>;
|
||||||
|
const peerDetailsNotice = peerDetailsUnavailable
|
||||||
|
&& (snapshot.torrentConnectedPeers ?? 0) > 0;
|
||||||
const queuePlacement = formatPropertiesQueuePlacement(
|
const queuePlacement = formatPropertiesQueuePlacement(
|
||||||
snapshot.queueName,
|
snapshot.queueName,
|
||||||
snapshot.queuePosition,
|
snapshot.queuePosition,
|
||||||
@@ -1297,7 +1269,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
<div className="properties-metric-card"><Download size={14} /><div><span>{t($ => $.properties.size)}</span><strong>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div>
|
<div className="properties-metric-card"><Download size={14} /><div><span>{t($ => $.properties.size)}</span><strong>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div>
|
||||||
<div className="properties-metric-card"><Gauge size={14} /><div><span>{t($ => $.properties.speed)}</span><strong>{snapshot.speed || '—'}</strong></div></div>
|
<div className="properties-metric-card"><Gauge size={14} /><div><span>{t($ => $.properties.speed)}</span><strong>{snapshot.speed || '—'}</strong></div></div>
|
||||||
<div className="properties-metric-card"><Timer size={14} /><div><span>{t($ => $.properties.eta)}</span><strong>{snapshot.eta || '—'}</strong></div></div>
|
<div className="properties-metric-card"><Timer size={14} /><div><span>{t($ => $.properties.eta)}</span><strong>{snapshot.eta || '—'}</strong></div></div>
|
||||||
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div><span>{connectionLabel}</span><strong>{connectionValue}</strong></div></div>}
|
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div><span className={connectionPresentation.labelKey === 'torrentPeersSeeders' ? 'properties-metric-label--wide' : undefined}>{connectionHeaderLabel}</span>{connectionValue}</div></div>}
|
||||||
{isTorrent && <>
|
{isTorrent && <>
|
||||||
<div className="properties-metric-card"><Upload size={14} /><div><span>{t($ => $.properties.torrentUploaded)}</span><strong>{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
|
<div className="properties-metric-card"><Upload size={14} /><div><span>{t($ => $.properties.torrentUploaded)}</span><strong>{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
|
||||||
<div className="properties-metric-card"><Activity size={14} /><div><span>{t($ => $.properties.torrentRatio)}</span><strong>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div>
|
<div className="properties-metric-card"><Activity size={14} /><div><span>{t($ => $.properties.torrentRatio)}</span><strong>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div>
|
||||||
@@ -1449,8 +1421,12 @@ export const PropertiesWindowApp = () => {
|
|||||||
<div className="properties-diagnostic-heading">
|
<div className="properties-diagnostic-heading">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="properties-diagnostic-label">{t($ => $.properties.torrentPeerDiagnostics)}</span>
|
<span className="properties-diagnostic-label">{t($ => $.properties.torrentPeerDiagnostics)}</span>
|
||||||
<p className="properties-diagnostic-value" data-value-state={peerDiagnosticState} role="status">
|
<p className="properties-diagnostic-value" data-value-state={peerDetailsNotice ? 'unavailable' : peerDiagnosticState} role="status">
|
||||||
{peers
|
{peerDetailsNotice
|
||||||
|
? t($ => $.properties.torrentPeerDetailsUnavailable, {
|
||||||
|
connected: formatPropertiesDiagnosticCount(snapshot.torrentConnectedPeers ?? 0, snapshot.appearance.locale),
|
||||||
|
})
|
||||||
|
: peers
|
||||||
? t($ => $.properties.torrentPeerCount, {
|
? t($ => $.properties.torrentPeerCount, {
|
||||||
total: formatPropertiesDiagnosticCount(peers.totalPeers, snapshot.appearance.locale),
|
total: formatPropertiesDiagnosticCount(peers.totalPeers, snapshot.appearance.locale),
|
||||||
seeders: formatPropertiesDiagnosticCount(peers.totalSeeders, snapshot.appearance.locale),
|
seeders: formatPropertiesDiagnosticCount(peers.totalSeeders, snapshot.appearance.locale),
|
||||||
@@ -1491,13 +1467,13 @@ export const PropertiesWindowApp = () => {
|
|||||||
<input id="properties-transfer-speed-cap" className="app-control w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
|
<input id="properties-transfer-speed-cap" className="app-control w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
|
||||||
</PropertiesField>
|
</PropertiesField>
|
||||||
<PropertiesField
|
<PropertiesField
|
||||||
label={connectionLabel}
|
label={connectionControlLabel}
|
||||||
controlId="properties-transfer-concurrency"
|
controlId="properties-transfer-concurrency"
|
||||||
hint={snapshot.isMedia === true ? t($ => $.properties.fragmentConcurrencyHint) : undefined}
|
hint={snapshot.isMedia === true ? t($ => $.properties.fragmentConcurrencyHint) : undefined}
|
||||||
className="max-w-md"
|
className="max-w-md"
|
||||||
>
|
>
|
||||||
<div className="mt-2 flex items-center gap-3" dir="ltr">
|
<div className="mt-2 flex items-center gap-3" dir="ltr">
|
||||||
<input id="properties-transfer-concurrency" type="range" min="1" max="16" value={connections || '1'} onChange={event => { setConnections(event.target.value); setDraftTab('transfer'); }} disabled={!editingEnabled} className="min-w-0 flex-1 accent-blue-500" aria-label={connectionLabel} />
|
<input id="properties-transfer-concurrency" type="range" min="1" max="16" value={connections || '1'} onChange={event => { setConnections(event.target.value); setDraftTab('transfer'); }} disabled={!editingEnabled} className="min-w-0 flex-1 accent-blue-500" aria-label={connectionControlLabel} />
|
||||||
<span className="w-8 text-center font-mono text-text-primary">{connections || '1'}</span>
|
<span className="w-8 text-center font-mono text-text-primary">{connections || '1'}</span>
|
||||||
</div>
|
</div>
|
||||||
</PropertiesField>
|
</PropertiesField>
|
||||||
@@ -1655,7 +1631,7 @@ export const PropertiesWindowApp = () => {
|
|||||||
{snapshot.credentialsRequired === true && <p className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200" role="alert">{t($ => $.properties.credentialsRequired)}</p>}
|
{snapshot.credentialsRequired === true && <p className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200" role="alert">{t($ => $.properties.credentialsRequired)}</p>}
|
||||||
{isSftp && <label className="block max-w-2xl text-xs text-text-muted">{t($ => $.properties.sftpHostKeyMd)}<input className="app-control mt-1 w-full font-mono" value={sftpHostKeyMd} onChange={event => { setSftpHostKeyMd(event.target.value); setDraftTab('advanced'); }} placeholder={t($ => $.properties.sftpHostKeyMdHint)} disabled={!editingEnabled} autoComplete="off" /><span className="mt-1 block text-[11px]">{t($ => $.properties.sftpHostKeyMdDescription)}</span></label>}
|
{isSftp && <label className="block max-w-2xl text-xs text-text-muted">{t($ => $.properties.sftpHostKeyMd)}<input className="app-control mt-1 w-full font-mono" value={sftpHostKeyMd} onChange={event => { setSftpHostKeyMd(event.target.value); setDraftTab('advanced'); }} placeholder={t($ => $.properties.sftpHostKeyMdHint)} disabled={!editingEnabled} autoComplete="off" /><span className="mt-1 block text-[11px]">{t($ => $.properties.sftpHostKeyMdDescription)}</span></label>}
|
||||||
<div className="grid max-w-2xl gap-3 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
<div className="grid max-w-2xl gap-3 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||||
<div><span className="text-text-muted">{connectionLabel}</span><p className="mt-1">{connectionValue}</p></div>
|
<div><span className="text-text-muted">{connectionHeaderLabel}</span><p className="mt-1">{connectionValue}</p></div>
|
||||||
<div><span className="text-text-muted">{t($ => $.properties.speedCap)}</span><p className="mt-1">{snapshot.speedLimit || '—'}</p></div>
|
<div><span className="text-text-muted">{t($ => $.properties.speedCap)}</span><p className="mt-1">{snapshot.speedLimit || '—'}</p></div>
|
||||||
<div><span className="text-text-muted">{t($ => $.properties.username)}</span><p className="mt-1">{snapshot.hasUsername ? '✓' : '—'}</p></div>
|
<div><span className="text-text-muted">{t($ => $.properties.username)}</span><p className="mt-1">{snapshot.hasUsername ? '✓' : '—'}</p></div>
|
||||||
<div><span className="text-text-muted">{t($ => $.properties.password)}</span><p className="mt-1">{snapshot.hasPassword ? '✓' : '—'}</p></div>
|
<div><span className="text-text-muted">{t($ => $.properties.password)}</span><p className="mt-1">{snapshot.hasPassword ? '✓' : '—'}</p></div>
|
||||||
|
|||||||
@@ -345,7 +345,6 @@ const common = {
|
|||||||
torrentWebSeedsRemove: 'Remove web seed',
|
torrentWebSeedsRemove: 'Remove web seed',
|
||||||
torrentWebSeedsInvalid: 'Each web-seed row needs a valid Torrent file and an HTTP(S) base URI without credentials or fragments.',
|
torrentWebSeedsInvalid: 'Each web-seed row needs a valid Torrent file and an HTTP(S) base URI without credentials or fragments.',
|
||||||
torrentPeerCount: '{{total}} peers — {{seeders}} seeders',
|
torrentPeerCount: '{{total}} peers — {{seeders}} seeders',
|
||||||
torrentPeerSummary: '{{total}} peers · {{seeders}} seeders',
|
|
||||||
torrentPeerDownload: 'Download',
|
torrentPeerDownload: 'Download',
|
||||||
torrentPeerUpload: 'Upload',
|
torrentPeerUpload: 'Upload',
|
||||||
torrentPeerSeeder: 'Seeder',
|
torrentPeerSeeder: 'Seeder',
|
||||||
@@ -358,6 +357,9 @@ const common = {
|
|||||||
torrentSeededDuration: 'Seeded',
|
torrentSeededDuration: 'Seeded',
|
||||||
torrentSeedTimeHint: 'How long this Torrent may continue seeding after its files finish downloading. Leave blank to use the default.',
|
torrentSeedTimeHint: 'How long this Torrent may continue seeding after its files finish downloading. Leave blank to use the default.',
|
||||||
torrentConnectedPeers: 'Peers',
|
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',
|
torrentSeeders: 'Seeders',
|
||||||
torrentUploadSpeed: 'Upload speed',
|
torrentUploadSpeed: 'Upload speed',
|
||||||
seconds: 'seconds',
|
seconds: 'seconds',
|
||||||
|
|||||||
@@ -345,7 +345,6 @@ const fa = {
|
|||||||
torrentWebSeedsRemove: 'حذف وبسید',
|
torrentWebSeedsRemove: 'حذف وبسید',
|
||||||
torrentWebSeedsInvalid: 'هر ردیف وبسید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.',
|
torrentWebSeedsInvalid: 'هر ردیف وبسید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.',
|
||||||
torrentPeerCount: '{{total}} همتا — {{seeders}} سید',
|
torrentPeerCount: '{{total}} همتا — {{seeders}} سید',
|
||||||
torrentPeerSummary: '{{total}} همتا · {{seeders}} سید',
|
|
||||||
torrentPeerDownload: 'دریافت',
|
torrentPeerDownload: 'دریافت',
|
||||||
torrentPeerUpload: 'آپلود',
|
torrentPeerUpload: 'آپلود',
|
||||||
torrentPeerSeeder: 'سید',
|
torrentPeerSeeder: 'سید',
|
||||||
@@ -358,6 +357,9 @@ const fa = {
|
|||||||
torrentSeededDuration: 'مدت سید',
|
torrentSeededDuration: 'مدت سید',
|
||||||
torrentSeedTimeHint: 'مدتی که تورنت پس از تکمیل دانلود به سید ادامه میدهد. برای استفاده از پیشفرض خالی بگذارید.',
|
torrentSeedTimeHint: 'مدتی که تورنت پس از تکمیل دانلود به سید ادامه میدهد. برای استفاده از پیشفرض خالی بگذارید.',
|
||||||
torrentConnectedPeers: 'همتاها',
|
torrentConnectedPeers: 'همتاها',
|
||||||
|
torrentPeersSeeders: 'همتاهای متصل / سیدهای متصل',
|
||||||
|
torrentConnectedPeerMetric: '{{peers}} همتای متصل / {{seeders}} سید متصل',
|
||||||
|
torrentPeerDetailsUnavailable: '{{connected}} همتای متصل گزارش شده، اما جزئیات همتاها هنوز در دسترس نیست.',
|
||||||
torrentSeeders: 'سیدها',
|
torrentSeeders: 'سیدها',
|
||||||
torrentUploadSpeed: 'سرعت آپلود',
|
torrentUploadSpeed: 'سرعت آپلود',
|
||||||
seconds: 'ثانیه',
|
seconds: 'ثانیه',
|
||||||
|
|||||||
@@ -345,7 +345,6 @@ const he = {
|
|||||||
torrentWebSeedsRemove: 'הסר זריעת Web',
|
torrentWebSeedsRemove: 'הסר זריעת Web',
|
||||||
torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.',
|
torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.',
|
||||||
torrentPeerCount: '{{total}} עמיתים — {{seeders}} משתפים',
|
torrentPeerCount: '{{total}} עמיתים — {{seeders}} משתפים',
|
||||||
torrentPeerSummary: '{{total}} עמיתים · {{seeders}} משתפים',
|
|
||||||
torrentPeerDownload: 'הורדה',
|
torrentPeerDownload: 'הורדה',
|
||||||
torrentPeerUpload: 'העלאה',
|
torrentPeerUpload: 'העלאה',
|
||||||
torrentPeerSeeder: 'משתף',
|
torrentPeerSeeder: 'משתף',
|
||||||
@@ -358,6 +357,9 @@ const he = {
|
|||||||
torrentSeededDuration: 'משך שיתוף',
|
torrentSeededDuration: 'משך שיתוף',
|
||||||
torrentSeedTimeHint: 'משך הזמן שבו הטורנט ימשיך לשתף לאחר סיום ההורדה. השאר ריק כדי להשתמש בברירת המחדל.',
|
torrentSeedTimeHint: 'משך הזמן שבו הטורנט ימשיך לשתף לאחר סיום ההורדה. השאר ריק כדי להשתמש בברירת המחדל.',
|
||||||
torrentConnectedPeers: 'עמיתים',
|
torrentConnectedPeers: 'עמיתים',
|
||||||
|
torrentPeersSeeders: 'עמיתים / משתפים',
|
||||||
|
torrentConnectedPeerMetric: '{{peers}} עמיתים מחוברים / {{seeders}} משתפים מחוברים',
|
||||||
|
torrentPeerDetailsUnavailable: 'דווחו {{connected}} עמיתים מחוברים, אך פרטי העמיתים עדיין אינם זמינים.',
|
||||||
torrentSeeders: 'משתפים',
|
torrentSeeders: 'משתפים',
|
||||||
torrentUploadSpeed: 'מהירות העלאה',
|
torrentUploadSpeed: 'מהירות העלאה',
|
||||||
seconds: 'שניות',
|
seconds: 'שניות',
|
||||||
|
|||||||
@@ -345,7 +345,6 @@ const ru = {
|
|||||||
torrentWebSeedsRemove: 'Удалить веб-сид',
|
torrentWebSeedsRemove: 'Удалить веб-сид',
|
||||||
torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.',
|
torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.',
|
||||||
torrentPeerCount: '{{total}} пиров — {{seeders}} сидеров',
|
torrentPeerCount: '{{total}} пиров — {{seeders}} сидеров',
|
||||||
torrentPeerSummary: '{{total}} пиров · {{seeders}} сидеров',
|
|
||||||
torrentPeerDownload: 'Загрузка',
|
torrentPeerDownload: 'Загрузка',
|
||||||
torrentPeerUpload: 'Отдача',
|
torrentPeerUpload: 'Отдача',
|
||||||
torrentPeerSeeder: 'Сидер',
|
torrentPeerSeeder: 'Сидер',
|
||||||
@@ -358,6 +357,9 @@ const ru = {
|
|||||||
torrentSeededDuration: 'Время раздачи',
|
torrentSeededDuration: 'Время раздачи',
|
||||||
torrentSeedTimeHint: 'Как долго Torrent продолжает раздачу после завершения загрузки. Оставьте пустым для значения по умолчанию.',
|
torrentSeedTimeHint: 'Как долго Torrent продолжает раздачу после завершения загрузки. Оставьте пустым для значения по умолчанию.',
|
||||||
torrentConnectedPeers: 'Пиры',
|
torrentConnectedPeers: 'Пиры',
|
||||||
|
torrentPeersSeeders: 'Пиры / Сиды',
|
||||||
|
torrentConnectedPeerMetric: '{{peers}} подключённых пиров / {{seeders}} подключённых сидов',
|
||||||
|
torrentPeerDetailsUnavailable: 'Подключённых пиров: {{connected}}, но сведения о них пока недоступны.',
|
||||||
torrentSeeders: 'Сиды',
|
torrentSeeders: 'Сиды',
|
||||||
torrentUploadSpeed: 'Скорость отдачи',
|
torrentUploadSpeed: 'Скорость отдачи',
|
||||||
seconds: 'секунд',
|
seconds: 'секунд',
|
||||||
|
|||||||
@@ -345,7 +345,6 @@ const uk = {
|
|||||||
torrentWebSeedsRemove: 'Видалити вебсід',
|
torrentWebSeedsRemove: 'Видалити вебсід',
|
||||||
torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.',
|
torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.',
|
||||||
torrentPeerCount: '{{total}} пірів — {{seeders}} сідів',
|
torrentPeerCount: '{{total}} пірів — {{seeders}} сідів',
|
||||||
torrentPeerSummary: '{{total}} пірів · {{seeders}} сідів',
|
|
||||||
torrentPeerDownload: 'Завантаження',
|
torrentPeerDownload: 'Завантаження',
|
||||||
torrentPeerUpload: 'Віддача',
|
torrentPeerUpload: 'Віддача',
|
||||||
torrentPeerSeeder: 'Сідер',
|
torrentPeerSeeder: 'Сідер',
|
||||||
@@ -358,6 +357,9 @@ const uk = {
|
|||||||
torrentSeededDuration: 'Час роздачі',
|
torrentSeededDuration: 'Час роздачі',
|
||||||
torrentSeedTimeHint: 'Як довго Torrent продовжує роздачу після завершення завантаження. Залиште порожнім для значення за замовчуванням.',
|
torrentSeedTimeHint: 'Як довго Torrent продовжує роздачу після завершення завантаження. Залиште порожнім для значення за замовчуванням.',
|
||||||
torrentConnectedPeers: 'Піри',
|
torrentConnectedPeers: 'Піри',
|
||||||
|
torrentPeersSeeders: 'Піри / Сіди',
|
||||||
|
torrentConnectedPeerMetric: '{{peers}} підключених пірів / {{seeders}} підключених сідів',
|
||||||
|
torrentPeerDetailsUnavailable: 'Підключених пірів: {{connected}}, але відомості про них поки недоступні.',
|
||||||
torrentSeeders: 'Сіди',
|
torrentSeeders: 'Сіди',
|
||||||
torrentUploadSpeed: 'Швидкість віддачі',
|
torrentUploadSpeed: 'Швидкість віддачі',
|
||||||
seconds: 'секунд',
|
seconds: 'секунд',
|
||||||
|
|||||||
@@ -345,7 +345,6 @@ const zhCN = {
|
|||||||
torrentWebSeedsRemove: '移除 Web 做种',
|
torrentWebSeedsRemove: '移除 Web 做种',
|
||||||
torrentWebSeedsInvalid: '每行 Web 做种都需要有效的 Torrent 文件和不含凭据或片段的 HTTP(S) 基础地址。',
|
torrentWebSeedsInvalid: '每行 Web 做种都需要有效的 Torrent 文件和不含凭据或片段的 HTTP(S) 基础地址。',
|
||||||
torrentPeerCount: '{{total}} 个节点 — {{seeders}} 个做种节点',
|
torrentPeerCount: '{{total}} 个节点 — {{seeders}} 个做种节点',
|
||||||
torrentPeerSummary: '{{total}} 个节点 · {{seeders}} 个做种节点',
|
|
||||||
torrentPeerDownload: '下载',
|
torrentPeerDownload: '下载',
|
||||||
torrentPeerUpload: '上传',
|
torrentPeerUpload: '上传',
|
||||||
torrentPeerSeeder: '做种',
|
torrentPeerSeeder: '做种',
|
||||||
@@ -358,6 +357,9 @@ const zhCN = {
|
|||||||
torrentSeededDuration: '做种时长',
|
torrentSeededDuration: '做种时长',
|
||||||
torrentSeedTimeHint: '文件下载完成后继续做种的时长。留空以使用默认值。',
|
torrentSeedTimeHint: '文件下载完成后继续做种的时长。留空以使用默认值。',
|
||||||
torrentConnectedPeers: '连接数',
|
torrentConnectedPeers: '连接数',
|
||||||
|
torrentPeersSeeders: '节点 / 做种',
|
||||||
|
torrentConnectedPeerMetric: '{{peers}} 个已连接节点 / {{seeders}} 个已连接做种节点',
|
||||||
|
torrentPeerDetailsUnavailable: '检测到 {{connected}} 个已连接节点,但其详细信息暂时不可用。',
|
||||||
torrentSeeders: '种子数',
|
torrentSeeders: '种子数',
|
||||||
torrentUploadSpeed: '上传速度',
|
torrentUploadSpeed: '上传速度',
|
||||||
seconds: '秒',
|
seconds: '秒',
|
||||||
|
|||||||
@@ -835,6 +835,16 @@ html[data-list-density="relaxed"] {
|
|||||||
white-space: nowrap;
|
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 {
|
.properties-metric-card strong {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: hsl(var(--text-primary));
|
color: hsl(var(--text-primary));
|
||||||
@@ -845,6 +855,18 @@ html[data-list-density="relaxed"] {
|
|||||||
white-space: nowrap;
|
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 {
|
.properties-window-destination {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import type { PlatformInfo } from './bindings/PlatformInfo';
|
|||||||
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
import type { QueueConcurrencyConfig } from './bindings/QueueConcurrencyConfig';
|
||||||
import type { TorrentMetadata } from './bindings/TorrentMetadata';
|
import type { TorrentMetadata } from './bindings/TorrentMetadata';
|
||||||
import type { TorrentPeerDiagnostics } from './bindings/TorrentPeerDiagnostics';
|
import type { TorrentPeerDiagnostics } from './bindings/TorrentPeerDiagnostics';
|
||||||
import type { TorrentPeerSummary } from './bindings/TorrentPeerSummary';
|
|
||||||
import type { TorrentFileProgressSnapshot } from './bindings/TorrentFileProgressSnapshot';
|
import type { TorrentFileProgressSnapshot } from './bindings/TorrentFileProgressSnapshot';
|
||||||
import type { TorrentPieceProgressSnapshot } from './bindings/TorrentPieceProgressSnapshot';
|
import type { TorrentPieceProgressSnapshot } from './bindings/TorrentPieceProgressSnapshot';
|
||||||
import type { TorrentWebSeed } from './bindings/TorrentWebSeed';
|
import type { TorrentWebSeed } from './bindings/TorrentWebSeed';
|
||||||
@@ -95,7 +94,6 @@ type CommandMap = {
|
|||||||
result: void;
|
result: void;
|
||||||
};
|
};
|
||||||
get_torrent_peers: { args: { id: string }; result: TorrentPeerDiagnostics };
|
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_file_progress: { args: { id: string }; result: TorrentFileProgressSnapshot };
|
||||||
get_torrent_piece_progress: { args: { id: string }; result: TorrentPieceProgressSnapshot };
|
get_torrent_piece_progress: { args: { id: string }; result: TorrentPieceProgressSnapshot };
|
||||||
get_torrent_file_selection: { args: { id: string }; result: TorrentFileSelectionSnapshot };
|
get_torrent_file_selection: { args: { id: string }; result: TorrentFileSelectionSnapshot };
|
||||||
|
|||||||
@@ -173,11 +173,11 @@ describe('Properties window bridge', () => {
|
|||||||
downloaded_bytes: 3,
|
downloaded_bytes: 3,
|
||||||
total_bytes: 4,
|
total_bytes: 4,
|
||||||
total_is_estimate: false,
|
total_is_estimate: false,
|
||||||
active_connections: 4,
|
active_connections: 0,
|
||||||
requested_connections: 8,
|
requested_connections: 8,
|
||||||
uploaded_bytes: 9,
|
uploaded_bytes: 9,
|
||||||
upload_speed: '1 MiB/s',
|
upload_speed: '1 MiB/s',
|
||||||
num_seeders: 6,
|
num_seeders: 0,
|
||||||
torrent_seeded_seconds: 12,
|
torrent_seeded_seconds: 12,
|
||||||
},
|
},
|
||||||
moveProgress: 0.5,
|
moveProgress: 0.5,
|
||||||
@@ -193,7 +193,8 @@ describe('Properties window bridge', () => {
|
|||||||
totalIsEstimate: false,
|
totalIsEstimate: false,
|
||||||
torrentUploadedBytes: 9,
|
torrentUploadedBytes: 9,
|
||||||
uploadSpeed: '1 MiB/s',
|
uploadSpeed: '1 MiB/s',
|
||||||
torrentSeeders: 6,
|
torrentConnectedPeers: 0,
|
||||||
|
torrentConnectedSeeders: 0,
|
||||||
torrentSeededSeconds: 12,
|
torrentSeededSeconds: 12,
|
||||||
moveProgress: 0.5,
|
moveProgress: 0.5,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -180,7 +180,8 @@ export type PropertiesSnapshot = SafePropertiesFields & {
|
|||||||
activeConnections?: number;
|
activeConnections?: number;
|
||||||
requestedConnections?: number;
|
requestedConnections?: number;
|
||||||
uploadSpeed?: string;
|
uploadSpeed?: string;
|
||||||
torrentSeeders?: number;
|
torrentConnectedPeers?: number;
|
||||||
|
torrentConnectedSeeders?: number;
|
||||||
moveProgress?: number;
|
moveProgress?: number;
|
||||||
hasPassword: boolean;
|
hasPassword: boolean;
|
||||||
hasCookies: boolean;
|
hasCookies: boolean;
|
||||||
@@ -425,9 +426,11 @@ const copyWithoutSecrets = (
|
|||||||
? { totalIsEstimate: live.progress.total_is_estimate }
|
? { totalIsEstimate: live.progress.total_is_estimate }
|
||||||
: {}),
|
: {}),
|
||||||
...(live.progress.active_connections !== undefined
|
...(live.progress.active_connections !== undefined
|
||||||
&& item.isTorrent !== true
|
? item.isTorrent === true
|
||||||
&& item.isMedia !== true
|
? { torrentConnectedPeers: live.progress.active_connections }
|
||||||
? { activeConnections: live.progress.active_connections }
|
: item.isMedia !== true
|
||||||
|
? { activeConnections: live.progress.active_connections }
|
||||||
|
: {}
|
||||||
: {}),
|
: {}),
|
||||||
...(item.isTorrent !== true
|
...(item.isTorrent !== true
|
||||||
&& item.isMedia !== true
|
&& item.isMedia !== true
|
||||||
@@ -440,8 +443,8 @@ const copyWithoutSecrets = (
|
|||||||
...(live.progress.upload_speed !== undefined
|
...(live.progress.upload_speed !== undefined
|
||||||
? { uploadSpeed: live.progress.upload_speed }
|
? { uploadSpeed: live.progress.upload_speed }
|
||||||
: {}),
|
: {}),
|
||||||
...(live.progress.num_seeders !== undefined
|
...(live.progress.num_seeders !== undefined && item.isTorrent === true
|
||||||
? { torrentSeeders: live.progress.num_seeders }
|
? { torrentConnectedSeeders: live.progress.num_seeders }
|
||||||
: {}),
|
: {}),
|
||||||
...(live.progress.torrent_seeded_seconds !== undefined
|
...(live.progress.torrent_seeded_seconds !== undefined
|
||||||
? { torrentSeededSeconds: live.progress.torrent_seeded_seconds }
|
? { torrentSeededSeconds: live.progress.torrent_seeded_seconds }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
formatPropertiesDiagnosticCount,
|
formatPropertiesDiagnosticCount,
|
||||||
getPropertiesAvailabilityDiagnosticState,
|
getPropertiesAvailabilityDiagnosticState,
|
||||||
getPropertiesPeerDiagnosticState,
|
getPropertiesPeerDiagnosticState,
|
||||||
|
hasLiveTorrentPeerWithoutDetails,
|
||||||
} from './propertiesDiagnostics';
|
} from './propertiesDiagnostics';
|
||||||
|
|
||||||
const emptyPeerDiagnostics = {
|
const emptyPeerDiagnostics = {
|
||||||
@@ -49,4 +50,11 @@ describe('Properties peer diagnostics presentation state', () => {
|
|||||||
expect(getPropertiesAvailabilityDiagnosticState(null, false, 'idle')).toBe('unavailable');
|
expect(getPropertiesAvailabilityDiagnosticState(null, false, 'idle')).toBe('unavailable');
|
||||||
expect(getPropertiesAvailabilityDiagnosticState(null, false, 'error')).toBe('error');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ export const formatPropertiesDiagnosticCount = (value: number, locale: string):
|
|||||||
return new Intl.NumberFormat(resolveAppLocale(locale)).format(value);
|
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 => {
|
export const formatPropertiesAvailability = (availability: number, locale: string): string => {
|
||||||
if (!Number.isFinite(availability) || availability < 0) return '—';
|
if (!Number.isFinite(availability) || availability < 0) return '—';
|
||||||
return new Intl.NumberFormat(resolveAppLocale(locale), { maximumFractionDigits: 2 }).format(availability);
|
return new Intl.NumberFormat(resolveAppLocale(locale), { maximumFractionDigits: 2 }).format(availability);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
@@ -65,26 +65,29 @@ describe('Properties connection presentation', () => {
|
|||||||
})).toEqual({
|
})).toEqual({
|
||||||
kind: 'torrent',
|
kind: 'torrent',
|
||||||
showHeaderMetric: true,
|
showHeaderMetric: true,
|
||||||
labelKey: 'torrentConnectedPeers',
|
labelKey: 'torrentPeersSeeders',
|
||||||
value: '—',
|
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({
|
expect(getPropertiesConnectionPresentation({
|
||||||
isMedia: false,
|
isMedia: false,
|
||||||
isTorrent: true,
|
isTorrent: true,
|
||||||
}, {
|
torrentConnectedPeers: 10,
|
||||||
totalPeers: 41,
|
torrentConnectedSeeders: 2,
|
||||||
totalSeeders: 2,
|
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
kind: 'torrent',
|
kind: 'torrent',
|
||||||
showHeaderMetric: true,
|
showHeaderMetric: true,
|
||||||
labelKey: 'torrentConnectedPeers',
|
labelKey: 'torrentPeersSeeders',
|
||||||
value: '—',
|
value: '—',
|
||||||
torrentPeerSummary: {
|
torrentPeerCounts: {
|
||||||
totalPeers: 41,
|
connectedPeers: 10,
|
||||||
totalSeeders: 2,
|
connectedSeeders: 2,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import type { PropertiesSnapshot } from '../propertiesBridge';
|
|||||||
import { resolveDownloadFraction } from './downloadProgress';
|
import { resolveDownloadFraction } from './downloadProgress';
|
||||||
|
|
||||||
export type PropertiesConnectionKind = 'media' | 'torrent' | 'aria2';
|
export type PropertiesConnectionKind = 'media' | 'torrent' | 'aria2';
|
||||||
export type PropertiesConnectionLabelKey = 'fragmentConcurrency' | 'torrentConnectedPeers' | 'connections';
|
export type PropertiesConnectionLabelKey = 'fragmentConcurrency' | 'torrentPeersSeeders' | 'connections';
|
||||||
export type PropertiesTorrentPeerSummary = {
|
export type PropertiesTorrentPeerCounts = {
|
||||||
totalPeers: number;
|
connectedPeers?: number;
|
||||||
totalSeeders: number;
|
connectedSeeders?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PropertiesConnectionPresentation = {
|
export type PropertiesConnectionPresentation = {
|
||||||
@@ -13,7 +13,7 @@ export type PropertiesConnectionPresentation = {
|
|||||||
showHeaderMetric: boolean;
|
showHeaderMetric: boolean;
|
||||||
labelKey: PropertiesConnectionLabelKey;
|
labelKey: PropertiesConnectionLabelKey;
|
||||||
value: string;
|
value: string;
|
||||||
torrentPeerSummary?: PropertiesTorrentPeerSummary;
|
torrentPeerCounts?: PropertiesTorrentPeerCounts;
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayCount = (value: number | undefined): string => value == null ? '—' : String(value);
|
const displayCount = (value: number | undefined): string => value == null ? '—' : String(value);
|
||||||
@@ -25,8 +25,7 @@ export const getPropertiesProgress = (
|
|||||||
: resolveDownloadFraction(snapshot);
|
: resolveDownloadFraction(snapshot);
|
||||||
|
|
||||||
export const getPropertiesConnectionPresentation = (
|
export const getPropertiesConnectionPresentation = (
|
||||||
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections'>,
|
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections' | 'torrentConnectedPeers' | 'torrentConnectedSeeders'>,
|
||||||
torrentPeerSummary?: PropertiesTorrentPeerSummary | null,
|
|
||||||
): PropertiesConnectionPresentation => {
|
): PropertiesConnectionPresentation => {
|
||||||
if (snapshot.isMedia === true) {
|
if (snapshot.isMedia === true) {
|
||||||
return {
|
return {
|
||||||
@@ -41,9 +40,12 @@ export const getPropertiesConnectionPresentation = (
|
|||||||
return {
|
return {
|
||||||
kind: 'torrent',
|
kind: 'torrent',
|
||||||
showHeaderMetric: true,
|
showHeaderMetric: true,
|
||||||
labelKey: 'torrentConnectedPeers',
|
labelKey: 'torrentPeersSeeders',
|
||||||
value: '—',
|
value: '—',
|
||||||
...(torrentPeerSummary ? { torrentPeerSummary } : {}),
|
torrentPeerCounts: {
|
||||||
|
connectedPeers: snapshot.torrentConnectedPeers,
|
||||||
|
connectedSeeders: snapshot.torrentConnectedSeeders,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user