diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index c55df92..514ea6e 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -346,6 +346,16 @@ 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 8b7cc95..b672efa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6783,12 +6783,57 @@ async fn remove_magnet_metadata_probe_dir(path: &std::path::Path) -> Result<(), } } +struct MagnetMetadataProbeTelemetry { + info_hash: String, + started_at: Instant, + outcome: Option<&'static str>, +} + +impl MagnetMetadataProbeTelemetry { + fn new(info_hash: &str, tracker_bearing: bool, proxy_configured: bool) -> Self { + log::debug!( + "magnet metadata probe started: info_hash={info_hash}, tracker_bearing={tracker_bearing}, proxy_configured={proxy_configured}" + ); + Self { + info_hash: info_hash.to_string(), + started_at: Instant::now(), + outcome: None, + } + } + + fn finish(&mut self, outcome: &'static str) { + if self.outcome.is_some() { + return; + } + self.outcome = Some(outcome); + log::debug!( + "magnet metadata probe finished: info_hash={}, outcome={outcome}, elapsed_ms={}", + self.info_hash, + self.started_at.elapsed().as_millis() + ); + } +} + +impl Drop for MagnetMetadataProbeTelemetry { + fn drop(&mut self) { + if self.outcome.is_none() { + log::debug!( + "magnet metadata probe finished: info_hash={}, outcome=canceled, elapsed_ms={}", + self.info_hash, + self.started_at.elapsed().as_millis() + ); + } + } +} + async fn resolve_magnet_metadata( app_handle: &tauri::AppHandle, state: &AppState, source: &str, id: &str, proxy: Option<&str>, + headers: Option<&str>, + cookies: Option<&str>, cache: bool, ) -> Result { let expected = crate::torrent::inspect_source(source)?; @@ -6798,6 +6843,7 @@ async fn resolve_magnet_metadata( .transpose()? .flatten(); if cache && crate::torrent::magnet_allows_cached_metadata(source) { + let cache_started_at = Instant::now(); match crate::torrent::read_cached_torrent_by_info_hash(app_handle, &expected.info_hash) .await { @@ -6806,6 +6852,11 @@ async fn resolve_magnet_metadata( crate::torrent::validate_info_hash(Some(&expected.info_hash), &parsed.info_hash)?; let torrent_path = crate::torrent::cache_torrent_bytes(app_handle, id, &bytes).await?; + log::debug!( + "magnet metadata cache hit: info_hash={}, elapsed_ms={}", + expected.info_hash, + cache_started_at.elapsed().as_millis() + ); return Ok(crate::torrent::to_metadata(parsed, Some(torrent_path))); } Ok(None) => {} @@ -6839,12 +6890,33 @@ async fn resolve_magnet_metadata( options.insert("connect-timeout".to_string(), serde_json::json!("20")); options.insert("timeout".to_string(), serde_json::json!("60")); options.insert("auto-file-renaming".to_string(), serde_json::json!("false")); - if let Some(proxy) = proxy_value { + if let Some(proxy) = proxy_value.as_deref() { options.insert("all-proxy".to_string(), serde_json::json!(proxy)); } + let mut header_list = Vec::new(); + if let Some(cookies) = cookies.map(str::trim).filter(|value| !value.is_empty()) { + header_list.push(format!("Cookie: {cookies}")); + } + if let Some(headers) = headers { + header_list.extend( + headers + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string), + ); + } + if !header_list.is_empty() { + options.insert("header".to_string(), serde_json::json!(header_list)); + } let client = std::sync::Arc::new(Aria2RpcClient { port, secret }); let sanitized_source = crate::torrent::sanitize_magnet_uri_for_aria2(source)?; + let mut telemetry = MagnetMetadataProbeTelemetry::new( + &expected.info_hash, + sanitized_source.contains("tr="), + proxy_value.is_some(), + ); let metadata_result = crate::torrent_probe::run_metadata_probe( client, &sanitized_source, @@ -6855,6 +6927,12 @@ async fn resolve_magnet_metadata( ) .await; + match &metadata_result { + Ok(_) => telemetry.finish("probe-success"), + Err(crate::torrent_probe::ProbeFailure::Metadata(_)) => telemetry.finish("metadata-failure"), + Err(crate::torrent_probe::ProbeFailure::Cleanup(_)) => telemetry.finish("cleanup-failure"), + } + let bytes = match metadata_result { Ok(bytes) => bytes, Err(crate::torrent_probe::ProbeFailure::Metadata(error)) => { @@ -6917,6 +6995,8 @@ async fn inspect_torrent( &source, &id, proxy.as_deref(), + headers.as_deref(), + cookies.as_deref(), cache == Some(true), ) .await @@ -7390,6 +7470,17 @@ 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, @@ -15467,7 +15558,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_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_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, 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 e12ecb0..88ef6ea 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -2661,14 +2661,11 @@ impl QueueManager { Ok(()) } - /// Return bounded peer diagnostics for the current Torrent GID. Endpoint - /// and peer-id fields live only in this response; they are not persisted. - /// The control lock and post-RPC mapping check prevent a late response from - /// being attributed to a replaced or terminal lifecycle. - pub async fn get_aria2_torrent_peers( + async fn get_aria2_torrent_peer_result( &self, id: &str, - ) -> Result { + result_kind: &str, + ) -> Result { let _control_guard = self.acquire_aria2_control(id).await; if !self.is_registered(id).await { return Err("Torrent peer diagnostics are unavailable for this lifecycle".to_string()); @@ -2710,8 +2707,6 @@ impl QueueManager { crate::redact_sensitive_text(&error) ) })?; - let diagnostics = parse_torrent_peer_diagnostics(result)?; - let still_current = self.is_registered(id).await && self .is_aria2_control_epoch_current(id, expected_mapping.epoch) @@ -2719,10 +2714,37 @@ impl QueueManager { && self.is_current_aria2_gid_mapping(&gid, &expected_mapping) && self.aria2_gid_for_download(id).as_deref() == Some(gid.as_str()); if !still_current { - return Err("Torrent lifecycle changed while reading peer diagnostics".to_string()); + return Err(format!("Torrent lifecycle changed while reading {result_kind}")); } - Ok(diagnostics) + Ok(result) + } + + /// Return bounded peer diagnostics for the current Torrent GID. Endpoint + /// and peer-id fields live only in this response; they are not persisted. + /// The control lock and post-RPC mapping check prevent a late response from + /// being attributed to a replaced or terminal lifecycle. + pub async fn get_aria2_torrent_peers( + &self, + id: &str, + ) -> Result { + let result = self + .get_aria2_torrent_peer_result(id, "peer diagnostics") + .await?; + 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 @@ -5933,20 +5955,46 @@ pub(crate) fn parse_torrent_availability( }) } -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())?; +fn torrent_peer_summary_from_array( + peers: &[serde_json::Value], +) -> Result { if peers.len() > MAX_TORRENT_PEER_RESPONSE { return Err("aria2.getPeers returned too many peers".to_string()); } if peers.iter().any(|peer| !peer.is_object()) { return Err("aria2.getPeers returned malformed peer data".to_string()); } - let total_peers = u32::try_from(peers.len()).unwrap_or(u32::MAX); let mut total_seeders = 0u32; + for peer in peers { + let peer = peer + .as_object() + .ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?; + if aria2_peer_bool(peer.get("seeder")) { + total_seeders = total_seeders.saturating_add(1); + } + } + Ok(crate::ipc::TorrentPeerSummary { + 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 mut sanitized = Vec::with_capacity(peers.len().min(MAX_TORRENT_PEER_DIAGNOSTICS)); for peer in peers.iter().take(MAX_TORRENT_PEER_DIAGNOSTICS) { @@ -5954,9 +6002,6 @@ pub(crate) fn parse_torrent_peer_diagnostics( .as_object() .ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?; let seeder = aria2_peer_bool(peer.get("seeder")); - if seeder { - total_seeders = total_seeders.saturating_add(1); - } sanitized.push(crate::ipc::TorrentPeer { ip: aria2_peer_ip(peer.get("ip")), port: aria2_peer_port(peer.get("port")), @@ -5968,21 +6013,9 @@ pub(crate) fn parse_torrent_peer_diagnostics( }); } - // Count seeders beyond the display cap without retaining any identifying - // peer data. The response is bounded by Aria2's per-Torrent peer limit, - // while the UI receives at most MAX_TORRENT_PEER_DIAGNOSTICS rows. - for peer in peers.iter().skip(MAX_TORRENT_PEER_DIAGNOSTICS) { - let peer = peer - .as_object() - .ok_or_else(|| "aria2.getPeers returned malformed peer data".to_string())?; - if aria2_peer_bool(peer.get("seeder")) { - total_seeders = total_seeders.saturating_add(1); - } - } - Ok(crate::ipc::TorrentPeerDiagnostics { - total_peers, - total_seeders, + total_peers: summary.total_peers, + total_seeders: summary.total_seeders, peers: sanitized, truncated: peers.len() > MAX_TORRENT_PEER_DIAGNOSTICS, }) @@ -8311,10 +8344,28 @@ 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] @@ -8324,6 +8375,11 @@ 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-tauri/src/torrent.rs b/src-tauri/src/torrent.rs index 4952560..a100021 100644 --- a/src-tauri/src/torrent.rs +++ b/src-tauri/src/torrent.rs @@ -443,6 +443,48 @@ fn collect_torrent_uris(value: Option<&BencodeValue>, schemes: &[&str]) -> Vec bool { + let BencodeValue::Bytes(bytes) = value else { + return false; + }; + let Ok(value) = String::from_utf8(bytes.clone()) else { + return false; + }; + bounded_uri(value.trim(), &["http", "https", "udp"]).is_some() +} + +fn torrent_tracker_metadata_is_safe(root: &BTreeMap, BencodeValue>) -> bool { + let mut tracker_count = 0usize; + let mut count_tracker = |value: &BencodeValue| { + tracker_count = tracker_count.saturating_add(1); + tracker_count <= 256 && torrent_tracker_uri_is_safe(value) + }; + + if let Some(announce) = root.get(b"announce".as_slice()) { + if !count_tracker(announce) { + return false; + } + } + + let Some(announce_list) = root.get(b"announce-list".as_slice()) else { + return true; + }; + let BencodeValue::List(tiers) = announce_list else { + return false; + }; + for tier in tiers { + let BencodeValue::List(trackers) = tier else { + return false; + }; + for tracker in trackers { + if !count_tracker(tracker) { + return false; + } + } + } + true +} + fn parse_torrent_web_seeds(value: Option<&BencodeValue>) -> Result, String> { let Some(value) = value else { return Ok(Vec::new()); @@ -546,7 +588,11 @@ pub fn torrent_details_from_bytes(bytes: &[u8]) -> Result Result { +/// Return whether validated metainfo may be reused for a Magnet identified by +/// its info hash. Tracker lists are safe to retain because they are peer +/// discovery metadata, while direct web-seed/source fields are deliberately +/// excluded so a later Magnet cannot inherit arbitrary HTTP resources. +pub fn torrent_metadata_is_safe_for_magnet_reuse(bytes: &[u8]) -> Result { if bytes.is_empty() || bytes.len() > MAX_TORRENT_BYTES { return Err(format!( "torrent metadata must be between 1 byte and {MAX_TORRENT_BYTES} bytes" @@ -561,11 +607,16 @@ pub fn torrent_metadata_is_safe_for_plain_magnet_reuse(bytes: &[u8]) -> Result bool { } has_info_hash = true; } - "dn" => {} + "dn" | "tr" => {} _ => return false, } } @@ -1076,7 +1127,7 @@ async fn read_cached_torrent_by_info_hash_unlocked( } Err(error) => return Err(format!("could not read cached torrent metadata: {error}")), }; - let reusable = torrent_metadata_is_safe_for_plain_magnet_reuse(&bytes).is_ok_and(|safe| safe); + let reusable = torrent_metadata_is_safe_for_magnet_reuse(&bytes).is_ok_and(|safe| safe); match parse_torrent_bytes(&bytes) { Ok(parsed) if reusable && parsed.info_hash == info_hash => {} Ok(_) | Err(_) => { @@ -1101,7 +1152,7 @@ pub async fn cache_torrent_info_hash( ) -> Result, String> { let _guard = canonical_torrent_cache_lock().lock().await; let parsed = parse_torrent_bytes(bytes)?; - if !torrent_metadata_is_safe_for_plain_magnet_reuse(bytes)? { + if !torrent_metadata_is_safe_for_magnet_reuse(bytes)? { return Ok(None); } let info_hash = parsed.info_hash; @@ -1302,16 +1353,32 @@ mod tests { } #[test] - fn plain_magnet_reuse_rejects_tracker_and_web_seed_metadata() { - assert!(torrent_metadata_is_safe_for_plain_magnet_reuse( + fn magnet_reuse_rejects_web_seed_metadata_but_retains_tracker_metadata() { + assert!(torrent_metadata_is_safe_for_magnet_reuse( b"d4:infod6:lengthi5e4:name4:testee" ) .expect("plain torrent metadata should parse")); - assert!(!torrent_metadata_is_safe_for_plain_magnet_reuse( - b"d8:announce1:x4:infod6:lengthi5e4:name4:testee" + assert!(torrent_metadata_is_safe_for_magnet_reuse( + b"d8:announce32:https://tracker.example/announce4:infod6:lengthi5e4:name4:testee" ) .expect("tracker-bearing torrent metadata should parse")); - assert!(!torrent_metadata_is_safe_for_plain_magnet_reuse( + assert!(torrent_metadata_is_safe_for_magnet_reuse( + b"d13:announce-listll32:https://tracker.example/announceee4:infod6:lengthi5e4:name4:testee" + ) + .expect("tracker-list-bearing torrent metadata should parse")); + assert!(!torrent_metadata_is_safe_for_magnet_reuse( + b"d8:announce30:ftp://tracker.example/announce4:infod6:lengthi5e4:name4:testee" + ) + .expect("unsupported tracker metadata should parse")); + assert!(!torrent_metadata_is_safe_for_magnet_reuse( + b"d8:announce42:https://user:pass@tracker.example/announce4:infod6:lengthi5e4:name4:testee" + ) + .expect("credential-bearing tracker metadata should parse")); + assert!(!torrent_metadata_is_safe_for_magnet_reuse( + b"d13:announce-listl1:xe4:infod6:lengthi5e4:name4:testee" + ) + .expect("malformed tracker-list metadata should parse")); + assert!(!torrent_metadata_is_safe_for_magnet_reuse( b"d4:infod6:lengthi5e4:name4:teste8:url-list1:xe" ) .expect("web-seed-bearing torrent metadata should parse")); @@ -1365,13 +1432,29 @@ mod tests { ); assert!(!path.exists()); + let tracker_bytes = b"d8:announce32:https://tracker.example/announce4:infod6:lengthi5e4:name4:testee"; + let tracker_hash = parse_torrent_bytes(tracker_bytes) + .expect("tracker-bearing torrent should parse") + .info_hash; + assert!( + cache_torrent_info_hash(app.handle(), tracker_bytes) + .await + .expect("tracker metadata should be reusable") + .is_some() + ); + assert_eq!( + read_cached_torrent_by_info_hash(app.handle(), &tracker_hash) + .await + .expect("tracker cache should be readable"), + Some(tracker_bytes.to_vec()) + ); assert!( cache_torrent_info_hash( app.handle(), - b"d8:announce1:x4:infod6:lengthi5e4:name4:testee" + b"d4:infod6:lengthi5e4:name4:teste8:url-list22:https://example.test/ae" ) .await - .expect("source-specific metadata should be handled") + .expect("web-seed metadata should be handled") .is_none() ); } @@ -1405,16 +1488,19 @@ mod tests { } #[test] - fn only_plain_magnets_can_reuse_hash_keyed_metadata() { + fn magnets_with_safe_parameters_can_reuse_hash_keyed_metadata() { assert!(magnet_allows_cached_metadata( "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567" )); assert!(magnet_allows_cached_metadata( "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example%20Torrent" )); - assert!(!magnet_allows_cached_metadata( + assert!(magnet_allows_cached_metadata( "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&tr=https%3A%2F%2Ftracker.invalid%2Fannounce" )); + assert!(magnet_allows_cached_metadata( + "magnet:?tr=udp%3A%2F%2Ftracker.invalid%3A1337%2Fannounce&xt=urn:btih:0123456789abcdef0123456789abcdef01234567" + )); assert!(!magnet_allows_cached_metadata( "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&ws=https%3A%2F%2Fexample.invalid%2Ffile" )); diff --git a/src/bindings/TorrentPeerSummary.ts b/src/bindings/TorrentPeerSummary.ts new file mode 100644 index 0000000..5c2923f --- /dev/null +++ b/src/bindings/TorrentPeerSummary.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TorrentPeerSummary = { totalPeers: number, totalSeeders: number, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 292113f..cf5c2d9 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -9,8 +9,9 @@ import { import { useSettingsStore } from '../store/useSettingsStore'; import type { DownloadItem } from '../bindings/DownloadItem'; import type { MediaPlaylistMetadata } from '../bindings/MediaPlaylistMetadata'; -import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react'; +import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, Copy, type LucideIcon } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; +import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads'; @@ -53,6 +54,7 @@ import { } from '../utils/addDownloadMetadata'; import { isTopmostModal, useModalFocus } from '../hooks/useModalFocus'; import { TorrentWebSeedEditor } from './TorrentWebSeedEditor'; +import { copyTorrentFilePath as writeTorrentFilePath } from '../utils/torrentFilePath'; const formatBytes = (bytes: number) => { const k = 1024; @@ -187,6 +189,16 @@ export const AddDownloadsModal = () => { }); }, []); + const copyTorrentFilePath = useCallback(async (path: string) => { + try { + await writeTorrentFilePath(path, writeClipboardText); + addToast({ message: t($ => $.logs.copied), variant: 'success' }); + } catch (error) { + console.warn('Failed to copy Torrent file path:', error); + addToast({ message: t($ => $.downloadTable.copyPathFailed), variant: 'error', isActionable: true }); + } + }, [addToast, t]); + useEffect(() => { if (!isAddModalOpen) cleanupDraftTorrentCache(); }, [cleanupDraftTorrentCache, isAddModalOpen]); @@ -2219,20 +2231,35 @@ export const AddDownloadsModal = () => { const selectedIndices = parsedItems[selectedItemIndex!].selectedTorrentFileIndices; const checked = !selectedIndices || selectedIndices.includes(file.index); return ( - + + + ); })} diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index 29f2d4a..36aa6b5 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -9,6 +9,7 @@ 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, @@ -51,6 +52,7 @@ import { 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 { WindowControls } from './WindowControls'; import { TORRENT_ENCRYPTION_POLICY_DISABLED, @@ -66,8 +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 = (status: string) => - ['downloading', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'paused'].includes(status); +const isTorrentPollingStatus = isTorrentPeerSummaryStatus; const isEditableStatus = (status: string) => !['downloading', 'processing', 'verifying', 'seeding', 'waitingToSeed', 'retrying', 'moving'].includes(status); @@ -214,6 +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 [availability, setAvailability] = useState(null); const [details, setDetails] = useState(null); const [diagnosticError, setDiagnosticError] = useState(''); @@ -267,11 +269,13 @@ 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()); @@ -289,6 +293,7 @@ export const PropertiesWindowApp = () => { downloadIdRef.current = downloadId; fileProgressRef.current = fileProgress; peersRef.current = peers; + peerSummaryRef.current = peerSummary; availabilityRef.current = availability; detailsRef.current = details; @@ -506,7 +511,19 @@ export const PropertiesWindowApp = () => { invoke('get_torrent_availability', { id }), ]); if (isCurrent()) { - if (peerResult.status === 'fulfilled') setPeers(peerResult.value); + 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); + } + } else if (isExpectedPropertiesDiagnosticUnavailable(peerResult.reason)) { + peerSummaryRef.current = null; + setPeerSummary(null); + } if (availabilityResult.status === 'fulfilled') setAvailability(availabilityResult.value); const peerOutcome = peerResult.status === 'fulfilled' ? 'success' @@ -571,6 +588,40 @@ 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; @@ -595,6 +646,8 @@ 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'; @@ -637,6 +690,8 @@ export const PropertiesWindowApp = () => { setFileProgress(null); setPeers(null); setAvailability(null); + peerSummaryRef.current = null; + setPeerSummary(null); setDiagnosticError(''); setDiagnosticsLoading(false); setDiagnosticsRefreshing(false); @@ -723,6 +778,8 @@ export const PropertiesWindowApp = () => { diagnosticLifecycleEpochRef.current += 1; diagnosticLifecycleKeyRef.current = ''; diagnosticAttemptsRef.current.clear(); + peerSummaryRef.current = null; + setPeerSummary(null); setSnapshot(null); draftTabRef.current = null; isDirtyRef.current = false; @@ -797,6 +854,8 @@ export const PropertiesWindowApp = () => { setFileProgress(null); setPeers(null); setAvailability(null); + peerSummaryRef.current = null; + setPeerSummary(null); setDiagnosticError(''); setDiagnosticsLoading(false); setDiagnosticsRefreshing(false); @@ -812,19 +871,30 @@ export const PropertiesWindowApp = () => { setFileProgress(null); setPeers(null); setAvailability(null); + peerSummaryRef.current = null; + setPeerSummary(null); + diagnosticLifecycleEpochRef.current += 1; diagnosticAttemptsRef.current.clear(); setDiagnosticPhase('idle'); setPeerDiagnosticPhase('idle'); setAvailabilityDiagnosticPhase('idle'); } void refreshDiagnostics(activeTab, downloadId); - if (!isTorrentPollingStatus(snapshot.status) || !['files', 'peers'].includes(activeTab)) return; + 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; // 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(() => void refreshDiagnostics(activeTab, downloadId), 1000); + const interval = window.setInterval(() => { + if (shouldPollDiagnostics) void refreshDiagnostics(activeTab, downloadId); + if (shouldPollSummary) void refreshPeerSummary(downloadId); + }, 1000); return () => window.clearInterval(interval); - }, [activeTab, downloadId, isTorrent, refreshDiagnostics, snapshot?.status]); + }, [activeTab, downloadId, isTorrent, refreshDiagnostics, refreshPeerSummary, snapshot?.status]); useEffect(() => { let disposed = false; @@ -1124,12 +1194,18 @@ export const PropertiesWindowApp = () => { ? t($ => $.addDownloads.unknownSize) : `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`); const statusLabel = t($ => $.downloads.status[snapshot.status]); - const connectionPresentation = getPropertiesConnectionPresentation(snapshot); + const connectionPresentation = getPropertiesConnectionPresentation(snapshot, peerSummary); const connectionLabel = connectionPresentation.labelKey === 'fragmentConcurrency' ? t($ => $.properties.fragmentConcurrency) : connectionPresentation.labelKey === 'torrentConnectedPeers' ? t($ => $.properties.torrentConnectedPeers) : t($ => $.properties.connections); + const connectionValue = connectionPresentation.torrentPeerSummary + ? t($ => $.properties.torrentPeerSummary, { + total: connectionPresentation.torrentPeerSummary.totalPeers, + seeders: connectionPresentation.torrentPeerSummary.totalSeeders, + }) + : connectionPresentation.value; const queuePlacement = formatPropertiesQueuePlacement( snapshot.queueName, snapshot.queuePosition, @@ -1221,7 +1297,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}{connectionPresentation.value}
} + {connectionPresentation.showHeaderMetric &&
{connectionLabel}{connectionValue}
} {isTorrent && <>
{t($ => $.properties.torrentUploaded)}{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}
{t($ => $.properties.torrentRatio)}{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}
@@ -1579,7 +1655,7 @@ export const PropertiesWindowApp = () => { {snapshot.credentialsRequired === true &&

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

} {isSftp && }
-
{connectionLabel}

{connectionPresentation.value}

+
{connectionLabel}

{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 af85cf3..8aae257 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -345,6 +345,7 @@ 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', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 809166a..470148a 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -345,6 +345,7 @@ const fa = { torrentWebSeedsRemove: 'حذف وب‌سید', torrentWebSeedsInvalid: 'هر ردیف وب‌سید باید فایل معتبر تورنت و نشانی پایهٔ HTTP(S) بدون اطلاعات ورود یا fragment داشته باشد.', torrentPeerCount: '{{total}} همتا — {{seeders}} سید', + torrentPeerSummary: '{{total}} همتا · {{seeders}} سید', torrentPeerDownload: 'دریافت', torrentPeerUpload: 'آپلود', torrentPeerSeeder: 'سید', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 28fb318..d659846 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -345,6 +345,7 @@ const he = { torrentWebSeedsRemove: 'הסר זריעת Web', torrentWebSeedsInvalid: 'כל שורת זריעת Web צריכה קובץ טורנט תקין וכתובת בסיס HTTP(S) ללא פרטי התחברות או fragment.', torrentPeerCount: '{{total}} עמיתים — {{seeders}} משתפים', + torrentPeerSummary: '{{total}} עמיתים · {{seeders}} משתפים', torrentPeerDownload: 'הורדה', torrentPeerUpload: 'העלאה', torrentPeerSeeder: 'משתף', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index f1a16ab..a32785b 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -345,6 +345,7 @@ const ru = { torrentWebSeedsRemove: 'Удалить веб-сид', torrentWebSeedsInvalid: 'В каждой строке веб-сида нужны допустимый файл торрента и базовый HTTP(S)-адрес без учётных данных или фрагмента.', torrentPeerCount: '{{total}} пиров — {{seeders}} сидеров', + torrentPeerSummary: '{{total}} пиров · {{seeders}} сидеров', torrentPeerDownload: 'Загрузка', torrentPeerUpload: 'Отдача', torrentPeerSeeder: 'Сидер', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 48b02a4..a6b2ff8 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -345,6 +345,7 @@ const uk = { torrentWebSeedsRemove: 'Видалити вебсід', torrentWebSeedsInvalid: 'Кожен рядок вебсіду має містити дійсний файл торента й базову HTTP(S)-адресу без облікових даних або фрагмента.', torrentPeerCount: '{{total}} пірів — {{seeders}} сідів', + torrentPeerSummary: '{{total}} пірів · {{seeders}} сідів', torrentPeerDownload: 'Завантаження', torrentPeerUpload: 'Віддача', torrentPeerSeeder: 'Сідер', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index ac26d15..a0d20e7 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -345,6 +345,7 @@ const zhCN = { torrentWebSeedsRemove: '移除 Web 做种', torrentWebSeedsInvalid: '每行 Web 做种都需要有效的 Torrent 文件和不含凭据或片段的 HTTP(S) 基础地址。', torrentPeerCount: '{{total}} 个节点 — {{seeders}} 个做种节点', + torrentPeerSummary: '{{total}} 个节点 · {{seeders}} 个做种节点', torrentPeerDownload: '下载', torrentPeerUpload: '上传', torrentPeerSeeder: '做种', diff --git a/src/index.css b/src/index.css index 73f97f8..75b6d1b 100644 --- a/src/index.css +++ b/src/index.css @@ -1446,6 +1446,11 @@ html[data-list-density="relaxed"] { color: hsl(var(--text-primary)); } + .app-icon-button:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } + .app-icon-button:active:not(:disabled) { background: hsl(var(--border-color)); transform: scale(0.94); diff --git a/src/ipc.ts b/src/ipc.ts index 8e31625..24ac9e1 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -20,6 +20,7 @@ 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'; @@ -94,6 +95,7 @@ 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 3b1245e..e04643b 100644 --- a/src/propertiesBridge.test.ts +++ b/src/propertiesBridge.test.ts @@ -191,7 +191,6 @@ describe('Properties window bridge', () => { downloadedBytes: 3, totalBytes: 4, totalIsEstimate: false, - connectedPeers: 4, torrentUploadedBytes: 9, uploadSpeed: '1 MiB/s', torrentSeeders: 6, @@ -403,6 +402,7 @@ describe('Properties window bridge', () => { expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent transfer has a stale control epoch'))).toBe(true); expect(isExpectedPropertiesDiagnosticUnavailable(new Error('active Torrent has a stale control epoch'))).toBe(true); expect(isExpectedPropertiesDiagnosticUnavailable(new Error('Torrent lifecycle changed while reading peer diagnostics'))).toBe(true); + expect(isExpectedPropertiesDiagnosticUnavailable(new Error('Torrent lifecycle changed while reading peer summary'))).toBe(true); expect(isExpectedPropertiesDiagnosticUnavailable(new Error('aria2.getPeers failed: unavailable response'))).toBe(false); expect(isExpectedPropertiesDiagnosticUnavailable(new Error('aria2.getFiles failed: connection refused'))).toBe(false); }); diff --git a/src/propertiesBridge.ts b/src/propertiesBridge.ts index cbfe348..96bc275 100644 --- a/src/propertiesBridge.ts +++ b/src/propertiesBridge.ts @@ -179,7 +179,6 @@ export type PropertiesSnapshot = SafePropertiesFields & { lastResolverFallback?: boolean; activeConnections?: number; requestedConnections?: number; - connectedPeers?: number; uploadSpeed?: string; torrentSeeders?: number; moveProgress?: number; @@ -425,9 +424,6 @@ const copyWithoutSecrets = ( ...(live.progress.total_is_estimate !== undefined ? { totalIsEstimate: live.progress.total_is_estimate } : {}), - ...(live.progress.active_connections !== undefined && item.isTorrent === true - ? { connectedPeers: live.progress.active_connections } - : {}), ...(live.progress.active_connections !== undefined && item.isTorrent !== true && item.isMedia !== true diff --git a/src/utils/propertiesPeerSummary.test.ts b/src/utils/propertiesPeerSummary.test.ts new file mode 100644 index 0000000..3767358 --- /dev/null +++ b/src/utils/propertiesPeerSummary.test.ts @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000..ad4f2ae --- /dev/null +++ b/src/utils/propertiesPeerSummary.ts @@ -0,0 +1,26 @@ +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 f59e5aa..3d4710d 100644 --- a/src/utils/propertiesPresentation.test.ts +++ b/src/utils/propertiesPresentation.test.ts @@ -58,16 +58,34 @@ describe('Properties connection presentation', () => { }); }); - it('uses connected peers for Torrents', () => { + it('does not use tellActive connections for the Torrent header', () => { expect(getPropertiesConnectionPresentation({ isMedia: false, isTorrent: true, - connectedPeers: 4, })).toEqual({ kind: 'torrent', showHeaderMetric: true, labelKey: 'torrentConnectedPeers', - value: '4', + value: '—', + }); + }); + + it('exposes the explicit live peer and seeder summary values', () => { + expect(getPropertiesConnectionPresentation({ + isMedia: false, + isTorrent: true, + }, { + totalPeers: 41, + totalSeeders: 2, + })).toEqual({ + kind: 'torrent', + showHeaderMetric: true, + labelKey: 'torrentConnectedPeers', + value: '—', + torrentPeerSummary: { + totalPeers: 41, + totalSeeders: 2, + }, }); }); }); diff --git a/src/utils/propertiesPresentation.ts b/src/utils/propertiesPresentation.ts index 42232d2..fe23f44 100644 --- a/src/utils/propertiesPresentation.ts +++ b/src/utils/propertiesPresentation.ts @@ -3,12 +3,17 @@ 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 PropertiesConnectionPresentation = { kind: PropertiesConnectionKind; showHeaderMetric: boolean; labelKey: PropertiesConnectionLabelKey; value: string; + torrentPeerSummary?: PropertiesTorrentPeerSummary; }; const displayCount = (value: number | undefined): string => value == null ? '—' : String(value); @@ -20,7 +25,8 @@ export const getPropertiesProgress = ( : resolveDownloadFraction(snapshot); export const getPropertiesConnectionPresentation = ( - snapshot: Pick, + snapshot: Pick, + torrentPeerSummary?: PropertiesTorrentPeerSummary | null, ): PropertiesConnectionPresentation => { if (snapshot.isMedia === true) { return { @@ -36,7 +42,8 @@ export const getPropertiesConnectionPresentation = ( kind: 'torrent', showHeaderMetric: true, labelKey: 'torrentConnectedPeers', - value: displayCount(snapshot.connectedPeers), + value: '—', + ...(torrentPeerSummary ? { torrentPeerSummary } : {}), }; } diff --git a/src/utils/torrentFilePath.test.ts b/src/utils/torrentFilePath.test.ts new file mode 100644 index 0000000..6131b76 --- /dev/null +++ b/src/utils/torrentFilePath.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from 'vitest'; +import { copyTorrentFilePath } from './torrentFilePath'; + +describe('Torrent file paths', () => { + it('copies the complete long path without changing its separators or characters', async () => { + const path = 'Season 03/Scenes/This-is-a-deliberately-long-file-name-with-unicode-字幕.mkv'; + const writeText = vi.fn(async () => undefined); + + await copyTorrentFilePath(path, writeText); + + expect(writeText).toHaveBeenCalledOnce(); + expect(writeText).toHaveBeenCalledWith(path); + }); +}); diff --git a/src/utils/torrentFilePath.ts b/src/utils/torrentFilePath.ts new file mode 100644 index 0000000..dc9b130 --- /dev/null +++ b/src/utils/torrentFilePath.ts @@ -0,0 +1,9 @@ +export type ClipboardWriter = (text: string) => Promise; + +/** Copy the exact Torrent-relative path without normalizing or truncating it. */ +export const copyTorrentFilePath = async ( + path: string, + writeText: ClipboardWriter, +): Promise => { + await writeText(path); +};