From b5ee53140dcebe16e521e05d9cfa646e130fb28c Mon Sep 17 00:00:00 2001 From: NimBold Date: Mon, 24 Aug 2026 15:51:24 +0330 Subject: [PATCH] fix(torrent): harden lifecycle and properties state - admit valid magnets immediately while keeping metadata refresh optional - fence Torrent recovery, diagnostics, and allocation presentation by lifecycle - avoid false peer-wait claims when telemetry is missing or malformed - stabilize Properties metric label/value layout for narrow and RTL windows Tests: - npm test -- --run - npm run build - npm run check:i18n - node --test scripts/*.node-test.js - cargo test --all-targets - npm run smoke:torrent - npm run smoke:torrent:failure-paths - git diff --check --- src-tauri/src/lib.rs | 173 +++++++++++++++++++++---- src-tauri/src/queue.rs | 55 +++++++- src-tauri/src/torrent.rs | 9 ++ src-tauri/tests/production_contract.rs | 7 + src/components/AddDownloadsModal.tsx | 20 +-- src/components/DownloadItem.tsx | 14 +- src/components/PropertiesWindowApp.tsx | 30 +++-- src/i18n/catalogs/en.ts | 1 + src/i18n/catalogs/fa.ts | 1 + src/i18n/catalogs/he.ts | 1 + src/i18n/catalogs/ru.ts | 1 + src/i18n/catalogs/uk.ts | 1 + src/i18n/catalogs/zh-CN.ts | 1 + src/index.css | 28 +++- src/utils/addDownloadMetadata.test.ts | 19 ++- src/utils/addDownloadMetadata.ts | 54 +++++--- src/utils/downloads.test.ts | 6 +- src/utils/downloads.ts | 13 +- src/utils/torrentPresentation.test.ts | 56 ++++++++ src/utils/torrentPresentation.ts | 46 +++++++ 20 files changed, 452 insertions(+), 84 deletions(-) create mode 100644 src/utils/torrentPresentation.test.ts create mode 100644 src/utils/torrentPresentation.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c565a5e..05d69a4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14708,6 +14708,37 @@ mod tests { ); } + #[test] + fn torrent_zero_progress_does_not_trigger_connection_recovery() { + let start = Instant::now(); + let mut observation = Aria2ConnectionObservation::default(); + + for offset in [0, 31, 62] { + assert_eq!( + observe_aria2_connections_with_epoch( + &mut observation, + Aria2ConnectionSample { + gid: "torrent-gid", + control_epoch: 4, + status: "active", + total: 2 * 1024 * 1024 * 1024, + completed: 0, + speed_bytes: 0.0, + active_connections: 0, + effective_connections: 1, + speed_limited: false, + is_torrent: true, + now: start + Duration::from_secs(offset), + }, + ), + None, + "peer discovery must not recycle a Torrent GID after a zero-byte wait" + ); + } + assert_eq!(observation.recovery_attempts, 0); + assert!(observation.no_progress_since.is_none()); + } + #[test] fn aria2_recovery_uses_a_cooldown_instead_of_a_one_shot_latch() { let start = Instant::now(); @@ -14798,6 +14829,7 @@ mod tests { active_connections: 16, effective_connections: 16, speed_limited: false, + is_torrent: false, now: start + Duration::from_secs(offset), }, ); @@ -14814,6 +14846,7 @@ mod tests { active_connections: 16, effective_connections: 16, speed_limited: false, + is_torrent: false, now: start + Duration::from_secs(31), }, ); @@ -14832,6 +14865,7 @@ mod tests { active_connections: 1, effective_connections: 16, speed_limited: false, + is_torrent: false, now: start + Duration::from_secs(62), }, ), @@ -14865,6 +14899,7 @@ mod tests { active_connections: 16, effective_connections: 16, speed_limited: false, + is_torrent: false, now, }, ), @@ -14887,6 +14922,7 @@ mod tests { active_connections: 16, effective_connections: 16, speed_limited: false, + is_torrent: false, now: now + Duration::from_secs(1), }, ); @@ -17466,6 +17502,9 @@ struct Aria2ConnectionObservation { last_completed: u64, last_logged_active_connections: Option, last_connection_logged_at: Option, + started_at: Option, + torrent_tracker_count: Option, + torrent_tracker_count_loaded: bool, seeder: bool, verifying: bool, } @@ -17480,6 +17519,7 @@ struct Aria2ConnectionSample<'a> { active_connections: i32, effective_connections: i32, speed_limited: bool, + is_torrent: bool, now: Instant, } @@ -17542,6 +17582,7 @@ fn observe_aria2_connections( active_connections, effective_connections, speed_limited, + is_torrent: false, now, }, ) @@ -17561,6 +17602,7 @@ fn observe_aria2_connections_with_epoch( active_connections, effective_connections, speed_limited, + is_torrent, now, } = sample; if observation.gid != gid || observation.control_epoch != control_epoch { @@ -17578,6 +17620,26 @@ fn observe_aria2_connections_with_epoch( }; } + // BitTorrent peer discovery is intentionally open-ended. A Torrent can + // remain active at zero bytes and zero connections while DHT, trackers, + // or PEX are still converging. Recreating its GID destroys that discovery + // lifecycle and was the source of the observed thirty-second restart + // loop. Missing-GID reconciliation remains owned by the poller and is + // therefore unaffected by this transfer-kind gate. + if is_torrent { + observation.started_at.get_or_insert(now); + observation.degraded_since = None; + observation.no_progress_since = None; + observation.last_active_connections = None; + observation.connection_decline_steps = 0; + observation.healthy_speed_samples = 0; + observation.healthy_samples_since_recovery = 0; + observation.recovery_attempts = 0; + observation.last_refreshed_at = None; + observation.last_completed = completed; + return None; + } + let remaining = total.saturating_sub(completed); let mut reason = None; if status == "active" && total > completed { @@ -18173,6 +18235,7 @@ pub fn run() { }; let torrent_startup_settings = crate::settings::torrent_startup_settings(persisted_settings.as_ref()); + let torrent_peer_discovery_for_poll = torrent_peer_discovery; let aria2_secret_clone = aria2_secret.clone(); let app_handle_bg = app.handle().clone(); @@ -18591,6 +18654,7 @@ pub fn run() { "numSeeders", "seeder", "connections", + "errorCode", "errorMessage", "verifiedLength", "verifyIntegrityPending" @@ -18663,6 +18727,9 @@ pub fn run() { .unwrap_or(requested_connections) .max(1); let speed_limited = poll_mgr.aria2_speed_limited(&id).await; + let is_torrent = poll_mgr.aria2_is_torrent(&id).await; + let is_verification = + poll_mgr.aria2_is_torrent_verification(&id).await; let control_epoch = mapping.epoch; // The status snapshot and both connection // lookups await. A pause, @@ -18677,10 +18744,33 @@ pub fn run() { { continue; } + let should_load_torrent_tracker_count = is_torrent + && observations + .get(&id) + .is_none_or(|observation| { + !observation.torrent_tracker_count_loaded + }); + let torrent_tracker_count = if should_load_torrent_tracker_count { + Some(poll_mgr.aria2_torrent_tracker_count(&id).await) + } else { + None + }; + if should_load_torrent_tracker_count + && (!poll_mgr.is_current_aria2_gid_mapping(gid, &mapping) + || !poll_mgr + .is_aria2_control_epoch_current(&id, control_epoch) + .await) + { + continue; + } seen_ids.insert(id.clone()); seen_gids.insert(gid.to_string()); let now = Instant::now(); let observation = observations.entry(id.clone()).or_default(); + if let Some(torrent_tracker_count) = torrent_tracker_count { + observation.torrent_tracker_count = torrent_tracker_count; + observation.torrent_tracker_count_loaded = true; + } let recovery_reason = observe_aria2_connections_with_epoch( observation, Aria2ConnectionSample { @@ -18693,15 +18783,13 @@ pub fn run() { active_connections, effective_connections, speed_limited, + is_torrent, now, }, ); let entering_seeding = is_seeder && !observation.seeder; observation.seeder = is_seeder; - let is_torrent = poll_mgr.aria2_is_torrent(&id).await; - let is_verification = - poll_mgr.aria2_is_torrent_verification(&id).await; if !poll_mgr.is_current_aria2_gid_mapping(gid, &mapping) || !poll_mgr .is_aria2_control_epoch_current(&id, control_epoch) @@ -18721,30 +18809,63 @@ pub fn run() { { continue; } - if !is_torrent - && (observation.last_logged_active_connections - != Some(active_connections) - || observation.last_connection_logged_at.is_none_or( - |logged_at| { - now.duration_since(logged_at) - >= ARIA2_CONNECTION_DIAGNOSTIC_INTERVAL - }, - )) + if observation.last_logged_active_connections + != Some(active_connections) + || observation.last_connection_logged_at.is_none_or( + |logged_at| { + now.duration_since(logged_at) + >= ARIA2_CONNECTION_DIAGNOSTIC_INTERVAL + }, + ) { - log::info!( - "aria2 progress [stage=connections id={} gid={} epoch={} retry_strike={} status={} active_connections={} requested_connections={} effective_connections={} completed_bytes={} total_bytes={} speed_bytes_per_second={}]", - id, - gid, - control_epoch, - retry_strike, - status, - active_connections, - requested_connections, - effective_connections, - completed, - total, - speed_bytes - ); + if is_torrent { + let error_code = status_info + .get("errorCode") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("none"); + let elapsed_ms = observation + .started_at + .map(|started_at| now.duration_since(started_at).as_millis()) + .unwrap_or_default(); + let seeders = num_seeders + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + log::info!( + "aria2 Torrent diagnostics [id={} gid={} epoch={} status={} error_code={} active_connections={} seeders={} elapsed_ms={} tracker_count={} dht={} dht6={} pex={} lpd={}]", + id, + gid, + control_epoch, + status, + error_code, + active_connections, + seeders, + elapsed_ms, + observation + .torrent_tracker_count + .map(|count| count.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + torrent_peer_discovery_for_poll.0, + torrent_peer_discovery_for_poll.1, + torrent_peer_discovery_for_poll.2, + torrent_peer_discovery_for_poll.3, + ); + } else { + log::info!( + "aria2 progress [stage=connections id={} gid={} epoch={} retry_strike={} status={} active_connections={} requested_connections={} effective_connections={} completed_bytes={} total_bytes={} speed_bytes_per_second={}]", + id, + gid, + control_epoch, + retry_strike, + status, + active_connections, + requested_connections, + effective_connections, + completed, + total, + speed_bytes + ); + } observation.last_logged_active_connections = Some(active_connections); observation.last_connection_logged_at = Some(now); diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 00794b5..d6a746a 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -2410,6 +2410,51 @@ impl QueueManager { .is_some_and(|payload| payload.is_torrent) } + /// Return only the bounded number of tracker endpoints associated with a + /// live Torrent. The endpoint values and managed metadata path never leave + /// this method; callers use the count for redacted diagnostics only. + pub async fn aria2_torrent_tracker_count(&self, id: &str) -> Option { + let payload = self.aria2_payloads.lock().await.get(id).cloned()?; + if !payload.is_torrent { + return None; + } + + let mut count = payload + .torrent_trackers + .as_deref() + .map(|trackers| { + trackers + .split(',') + .filter(|tracker| !tracker.trim().is_empty()) + .take(256) + .count() + }); + + if payload + .url + .trim_start() + .to_ascii_lowercase() + .starts_with("magnet:") + { + let magnet_count = crate::torrent::magnet_tracker_count(&payload.url).ok()?; + count = Some(count.unwrap_or_default().saturating_add(magnet_count)); + } + + if let Some(torrent_path) = payload.torrent_path.as_deref() { + let path = crate::torrent::validate_managed_torrent_path( + &self.app_handle, + id, + torrent_path, + ) + .ok()?; + let bytes = crate::torrent::read_bounded_torrent_bytes(&path).await.ok()?; + let metadata = crate::torrent::torrent_details_from_bytes(&bytes).ok()?; + count = Some(count.unwrap_or_default().saturating_add(metadata.trackers.len())); + } + + count.map(|value| value.min(256)) + } + pub async fn aria2_is_torrent_verification(&self, id: &str) -> bool { self.aria2_payloads .lock() @@ -4046,14 +4091,10 @@ impl QueueManager { } pub fn aria2_allocation_phase_eligible(payload: &SpawnPayload) -> bool { - if payload.is_media || payload.torrent_verify_only { + if payload.is_media || payload.is_torrent || payload.torrent_verify_only { return false; } - if !payload.is_torrent { - return true; - } - normalize_torrent_file_allocation(payload.torrent_file_allocation.as_deref()) - .is_ok_and(|allocation| allocation != "none") + true } async fn begin_aria2_allocation( @@ -8798,7 +8839,7 @@ mod tests { ..SpawnPayload::default() } )); - assert!(QueueManager::::aria2_allocation_phase_eligible( + assert!(!QueueManager::::aria2_allocation_phase_eligible( &SpawnPayload { is_torrent: true, torrent_file_allocation: Some("prealloc".to_string()), diff --git a/src-tauri/src/torrent.rs b/src-tauri/src/torrent.rs index e6f3c4b..93b87dc 100644 --- a/src-tauri/src/torrent.rs +++ b/src-tauri/src/torrent.rs @@ -697,6 +697,14 @@ fn normalized_magnet_trackers(parsed: &url::Url) -> Result, String> Ok(trackers) } +/// Return the bounded tracker count from a Magnet without exposing the +/// tracker values or the source URI to diagnostics. +pub fn magnet_tracker_count(source: &str) -> Result { + let parsed = url::Url::parse(source.trim()).map_err(|_| "invalid magnet URI".to_string())?; + validate_magnet_authority(&parsed)?; + Ok(normalized_magnet_trackers(&parsed)?.len()) +} + /// Return the Magnet URI form that Firelink may hand to Aria2. Direct source /// parameters can make Aria2 fetch arbitrary HTTP/FTP/SFTP resources during /// metadata resolution, so keep the peer/tracker identity parameters but @@ -1394,6 +1402,7 @@ mod tests { let valid = "magnet:?xt=urn:btih:0123456789012345678901234567890123456789&tr=https%3A%2F%2Ftracker.example%2Fannounce"; assert!(magnet_allows_cached_metadata(valid)); assert!(sanitize_magnet_uri_for_aria2(valid).is_ok()); + assert_eq!(magnet_tracker_count(valid).unwrap(), 1); for suffix in [ "&tr=ftp%3A%2F%2Ftracker.example%2Fannounce", diff --git a/src-tauri/tests/production_contract.rs b/src-tauri/tests/production_contract.rs index 6aecf61..c52b169 100644 --- a/src-tauri/tests/production_contract.rs +++ b/src-tauri/tests/production_contract.rs @@ -108,6 +108,13 @@ fn headless_queue_lifecycle_eligibility_and_retry_contracts_hold() { ..SpawnPayload::default() }) ); + assert!( + !QueueManager::::aria2_allocation_phase_eligible(&SpawnPayload { + is_torrent: true, + torrent_file_allocation: Some("prealloc".to_string()), + ..SpawnPayload::default() + }) + ); assert_eq!(backoff_for(0), Duration::from_secs(2)); assert_eq!(backoff_for(usize::MAX), Duration::from_secs(10)); assert_eq!( diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 289864e..97c3cc6 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -47,6 +47,7 @@ import { playlistFilePrefix, reconcileDownloadRows, refreshFailedMetadataRows, + isMagnetUrl, selectExactMediaSelection, updateRowIfCurrent, type AddDownloadDraftRow, @@ -1558,17 +1559,13 @@ export const AddDownloadsModal = () => { targetId: id }); cachedTorrentDraftIdsRef.current.delete(item.torrentCacheId || item.id); - } else { + } else if (!isMagnetUrl(item.sourceUrl)) { // Keep a safe fallback for rows restored from an older draft // shape that did not retain the preview cache identity. - const proxy = item.sourceUrl.trim().toLowerCase().startsWith('magnet:') - ? await getProxyArgs(useSettingsStore.getState()) - : undefined; const torrentData = await invoke('inspect_torrent', { source: item.sourceUrl, id, cache: true, - proxy: proxy ?? undefined, headers: headersForRow(contextUrl) || undefined, cookies: cookiesForRow(contextUrl, item.sourceUrl) || undefined, cookieScopes: requestContextForUrl(contextUrl)?.cookieScopes || undefined, @@ -1920,14 +1917,19 @@ export const AddDownloadsModal = () => { : `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}` : 'Unknown'; const canSubmit = canSubmitMetadataRows(parsedItems); - const failedMetadataCount = selectedItems.filter(item => item.status === 'metadata-error').length; + const failedMetadataCount = selectedItems.filter(item => + item.status === 'metadata-error' || item.status === 'fallback' + ).length; const failedMediaMetadataCount = selectedItems.filter( item => item.status === 'metadata-error' && item.isMedia ).length; const blockedMetadataCount = selectedItems.filter( item => item.metadataBlockedReason === 'unsafe-url' ).length; - const fallbackMetadataCount = failedMetadataCount - failedMediaMetadataCount - blockedMetadataCount; + const fallbackMetadataCount = selectedItems.filter(item => + (item.status === 'fallback' || (item.status === 'metadata-error' && !item.isMedia)) + && item.metadataBlockedReason !== 'unsafe-url' + ).length; const readyMetadataCount = selectedItems.filter(item => item.status === 'ready').length; const hasCustomTorrentOptions = Boolean( torrentMaxPeers.trim() @@ -2227,7 +2229,9 @@ export const AddDownloadsModal = () => { {item.isPlaylist ? t($ => $.addDownloads.fetchingPlaylist) : t($ => $.addDownloads.fetching)} ) : ( - item.status === 'metadata-error' + item.status === 'fallback' + ? t($ => $.addDownloads.fallback) + : item.status === 'metadata-error' ? item.metadataBlockedReason === 'unsafe-url' ? t($ => $.addDownloads.unsafeUrl) : item.isPlaylist ? t($ => $.addDownloads.playlistFailed) : item.isMedia ? t($ => $.addDownloads.metadataFailed) : t($ => $.addDownloads.fallback) : item.status === 'invalid' ? t($ => $.addDownloads.invalid) diff --git a/src/components/DownloadItem.tsx b/src/components/DownloadItem.tsx index 814b2c9..012c16f 100644 --- a/src/components/DownloadItem.tsx +++ b/src/components/DownloadItem.tsx @@ -19,6 +19,7 @@ import { resolveDownloadSizeDisplay, resolveDownloadFraction } from '../utils/downloadProgress'; +import { isTorrentWaitingForPeers } from '../utils/torrentPresentation'; import { COLUMN_ALIGNMENT_JUSTIFY, getDownloadActionPosition, @@ -84,7 +85,16 @@ export const DownloadItem = React.memo(({ const [isActionHovered, setIsActionHovered] = React.useState(false); const [isActionFocused, setIsActionFocused] = React.useState(false); const [actionPosition, setActionPosition] = React.useState(); - const allocationVisible = isAllocationPhaseVisible(allocationPending, download.status); + const waitingForPeers = isTorrentWaitingForPeers({ + isTorrent: download.isTorrent, + status: download.status, + downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes, + fraction: liveProgress?.fraction ?? download.fraction, + connectedPeers: liveProgress?.active_connections, + connectedSeeders: liveProgress?.num_seeders, + }); + const allocationVisible = download.isTorrent !== true + && isAllocationPhaseVisible(allocationPending, download.status); const hasRowActions = download.status !== 'completed'; const isBulkSelection = isSelected && selectedDownloadCount > 1; const pauseSelectionCount = isBulkSelection && selectedActionCounts.pause > 0 @@ -238,6 +248,8 @@ export const DownloadItem = React.memo(({ })(); const downloadStatusLabel = allocationVisible ? t($ => $.downloads.status.allocatingFiles) + : waitingForPeers + ? t($ => $.downloads.status.waitingForPeers) : t($ => $.downloads.status[download.status]); const visibleErrorStatusLabel = download.credentialsRequired === true ? t($ => $.properties.credentialsRequired) diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index 9629467..465d171 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -54,6 +54,7 @@ import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs'; import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation'; import { isTorrentLiveStatus } from '../utils/propertiesTorrentLifecycle'; +import { isTorrentWaitingForPeers } from '../utils/torrentPresentation'; import { WindowControls } from './WindowControls'; import { TORRENT_ENCRYPTION_POLICY_DISABLED, @@ -1165,12 +1166,23 @@ export const PropertiesWindowApp = () => { }); const isPromptFooter = footerActions.includes('keepEditing'); const fileSelectionEditingEnabled = editingEnabled && isTorrentFileSelectionEditable(snapshot.status); - const allocationPending = isAllocationPhaseVisible(snapshot.allocationPending === true, snapshot.status); + const waitingForPeers = isTorrentWaitingForPeers({ + isTorrent: snapshot.isTorrent, + status: snapshot.status, + downloadedBytes: snapshot.downloadedBytes, + fraction: snapshot.fraction, + connectedPeers: snapshot.torrentConnectedPeers, + connectedSeeders: snapshot.torrentConnectedSeeders, + }); + const allocationPending = snapshot.isTorrent !== true + && isAllocationPhaseVisible(snapshot.allocationPending === true, snapshot.status); const total = snapshot.size || (snapshot.totalBytes === undefined ? t($ => $.addDownloads.unknownSize) : `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`); const statusLabel = allocationPending ? t($ => $.downloads.status.allocatingFiles) + : waitingForPeers + ? t($ => $.downloads.status.waitingForPeers) : t($ => $.downloads.status[snapshot.status]); const connectionPresentation = getPropertiesConnectionPresentation(snapshot); const connectionHeaderLabel = connectionPresentation.labelKey === 'fragmentConcurrency' @@ -1192,7 +1204,7 @@ export const PropertiesWindowApp = () => { snapshot.appearance.locale, ); return $.properties.torrentConnectedPeerMetric, { peers: peersValue, seeders: seedersValue, @@ -1203,7 +1215,7 @@ export const PropertiesWindowApp = () => { {seedersValue} ; })() - : {connectionPresentation.value}; + : {connectionPresentation.value}; const peerDetailsNotice = peerDetailsUnavailable && (snapshot.torrentConnectedPeers ?? 0) > 0; const queuePlacement = formatPropertiesQueuePlacement( @@ -1317,13 +1329,13 @@ export const PropertiesWindowApp = () => { {progressPercent}
-
{t($ => $.properties.size)}{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}
-
{t($ => $.properties.speed)}{allocationPending ? '—' : snapshot.speed || '—'}
-
{t($ => $.properties.eta)}{allocationPending ? '—' : snapshot.eta || '—'}
- {connectionPresentation.showHeaderMetric &&
{connectionHeaderLabel}{connectionValue}
} +
{t($ => $.properties.size)}{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}
+
{t($ => $.properties.speed)}{allocationPending ? '—' : snapshot.speed || '—'}
+
{t($ => $.properties.eta)}{allocationPending ? '—' : snapshot.eta || '—'}
+ {connectionPresentation.showHeaderMetric &&
{connectionHeaderLabel}{connectionValue}
} {isTorrent && <> -
{t($ => $.properties.torrentUploaded)}{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}
-
{t($ => $.properties.torrentRatio)}{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}
+
{t($ => $.properties.torrentUploaded)}{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}
+
{t($ => $.properties.torrentRatio)}{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}
}
{snapshot.destination || '—'}
diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 6e11b98..8bd024a 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -91,6 +91,7 @@ const common = { staged: 'In queue', queued: 'Queued', downloading: 'Downloading', + waitingForPeers: 'Waiting for peers', processing: 'Processing', verifying: 'Verifying', seeding: 'Seeding', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index e814bac..7eabbea 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -91,6 +91,7 @@ const fa = { staged: 'در صف', queued: 'در صف', downloading: 'در حال دانلود', + waitingForPeers: 'در انتظار همتاها', processing: 'در حال پردازش', verifying: 'در حال بررسی صحت', seeding: 'در حال اشتراک‌گذاری', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index 71cf2a4..96bef7b 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -91,6 +91,7 @@ const he = { staged: 'בתור', queued: 'בתור', downloading: 'מוריד', + waitingForPeers: 'ממתין לעמיתים', processing: 'מעבד', verifying: 'מאמת', seeding: 'משתף', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 2b9f20c..eab52b4 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -91,6 +91,7 @@ const ru = { staged: 'В очереди', queued: 'В очереди', downloading: 'Загрузка', + waitingForPeers: 'Ожидание пиров', processing: 'Обработка', verifying: 'Проверка', seeding: 'Раздача', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index 864b2dd..c5b565c 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -91,6 +91,7 @@ const uk = { staged: 'У черзі', queued: 'У черзі', downloading: 'Завантаження', + waitingForPeers: 'Очікування пірів', processing: 'Обробка', verifying: 'Перевірка', seeding: 'Роздача', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index d53dcad..aad0d47 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -91,6 +91,7 @@ const zhCN = { staged: '在队列中', queued: '已排队', downloading: '下载中', + waitingForPeers: '等待节点', processing: '处理中', verifying: '校验中', seeding: '做种中', diff --git a/src/index.css b/src/index.css index e4bf29d..49da11f 100644 --- a/src/index.css +++ b/src/index.css @@ -823,14 +823,16 @@ html[data-list-density="relaxed"] { color: hsl(var(--accent-color)); } - .properties-metric-card > div { + .properties-metric-card > .properties-metric-content { display: grid; min-width: 0; gap: 2px; + grid-template-rows: minmax(1.25em, auto) auto; } - .properties-metric-card span { + .properties-metric-card > .properties-metric-content > .properties-metric-label { overflow: hidden; + min-height: 1.25em; color: hsl(var(--text-muted)); font-size: 10px; font-weight: 650; @@ -840,17 +842,17 @@ html[data-list-density="relaxed"] { white-space: nowrap; } - .properties-metric-card .properties-metric-label--wide { + .properties-metric-card > .properties-metric-content > .properties-metric-label--wide { overflow: visible; font-size: 9px; letter-spacing: 0.01em; line-height: 1.15; - min-height: 20px; + min-height: 2.3em; text-overflow: clip; white-space: normal; } - .properties-metric-card strong { + .properties-metric-card > .properties-metric-content > .properties-metric-value { overflow: hidden; color: hsl(var(--text-primary)); font-size: 13px; @@ -860,7 +862,7 @@ html[data-list-density="relaxed"] { white-space: nowrap; } - .properties-metric-card .properties-torrent-peer-count { + .properties-metric-card > .properties-metric-content > .properties-torrent-peer-count { display: inline-flex; overflow: visible; align-items: baseline; @@ -868,8 +870,20 @@ html[data-list-density="relaxed"] { text-overflow: clip; } - .properties-metric-card .properties-torrent-peer-count-connected { + .properties-metric-card > .properties-metric-content > .properties-torrent-peer-count > .properties-torrent-peer-count-connected { color: hsl(var(--accent-color)); + font: inherit; + letter-spacing: normal; + text-transform: none; + white-space: nowrap; + } + + .properties-metric-card > .properties-metric-content > .properties-torrent-peer-count > span[aria-hidden="true"] { + color: inherit; + font: inherit; + letter-spacing: normal; + text-transform: none; + white-space: nowrap; } .properties-window-destination { diff --git a/src/utils/addDownloadMetadata.test.ts b/src/utils/addDownloadMetadata.test.ts index 8599cd3..5bb27a2 100644 --- a/src/utils/addDownloadMetadata.test.ts +++ b/src/utils/addDownloadMetadata.test.ts @@ -12,6 +12,7 @@ import { mediaTypeForFormat, metadataSummaryMessage, isYouTubePlaylistUrl, + isMagnetUrl, isRemoteTorrentUrl, playlistFilePrefix, reconcileDownloadRows, @@ -95,7 +96,7 @@ describe('add download metadata workflow', () => { expect(rows[0]).toMatchObject({ isTorrent: true, isMedia: false, - status: 'loading' + status: 'fallback' }); expect(rows[1]).toMatchObject({ isTorrent: true, @@ -105,6 +106,7 @@ describe('add download metadata workflow', () => { }); expect(rows[0].torrentCacheId).toBe(`${rows[0].id}-1`); expect(rows[1].torrentCacheId).toBe(`${rows[1].id}-1`); + expect(isMagnetUrl(rows[0].sourceUrl)).toBe(true); }); it('admits remote .torrent URLs through the Torrent metadata path', () => { @@ -533,6 +535,21 @@ describe('add download metadata workflow', () => { expect(refreshed[1]).toMatchObject({ status: 'loading', generation: 5 }); }); + it('refreshes an admitted magnet only when metadata is explicitly requested', () => { + const magnet = 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567'; + const admitted = reconcileDownloadRows(magnet, [])[0]; + + expect(admitted.status).toBe('fallback'); + expect(canSubmitMetadataRows([admitted])).toBe(true); + + const refreshed = refreshFailedMetadataRows([admitted])[0]; + expect(refreshed).toMatchObject({ + status: 'loading', + generation: 2, + torrentCacheId: `${admitted.id}-2`, + }); + }); + it('ignores stale metadata results after generation changes', () => { const current = row({ generation: 2, status: 'loading' }); const updated = updateRowIfCurrent( diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index efc60e2..2cb01b7 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -9,7 +9,7 @@ import type { TorrentWebSeedDraft } from './downloads'; import i18n from '../i18n'; import { localePluralVariant } from '../i18n/locales'; -export type MetadataStatus = 'loading' | 'ready' | 'metadata-error' | 'invalid'; +export type MetadataStatus = 'loading' | 'ready' | 'fallback' | 'metadata-error' | 'invalid'; export interface AddMediaFormat { name: string; @@ -106,6 +106,14 @@ export const isRemoteTorrentUrl = (value: string): boolean => { } }; +export const isMagnetUrl = (value: string): boolean => { + try { + return new URL(value).protocol === 'magnet:'; + } catch { + return false; + } +}; + type ParsedInput = { identity: string; sourceUrl: string; @@ -274,7 +282,7 @@ export const reconcileDownloadRows = ( file: contextChanged || playlistContextChanged ? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl)) : preserved.file, - status: 'loading', + status: input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'fallback' : 'loading', generation: nextGeneration, requestContextVersion, isMedia: preserved.isMedia || forcedMedia || Boolean(input.playlistSourceUrl), @@ -321,7 +329,13 @@ export const reconcileDownloadRows = ( sourceUrl: input.sourceUrl, downloadUrl: input.sourceUrl, file: fallback, - status: input.valid ? 'loading' : 'invalid', + // A magnet already contains the transfer identity. Metadata is useful + // for the preview, but it is not required to admit the transfer. Keep + // the optional probe behind Refresh Metadata so the Add window never + // blocks a valid magnet on the bounded native probe timeout. + status: input.valid + ? input.isTorrent && isMagnetUrl(input.sourceUrl) ? 'fallback' : 'loading' + : 'invalid', generation, requestContextVersion: input.requestContextVersion, isMedia: input.valid && ( @@ -389,22 +403,28 @@ export const updateRowIfCurrent = ( export const refreshFailedMetadataRows = ( rows: AddDownloadDraftRow[] -): AddDownloadDraftRow[] => rows.map(row => - row.status === 'metadata-error' - ? { - ...row, - status: 'loading', - generation: row.generation + 1, - metadataBlockedReason: undefined - } - : row -); +): AddDownloadDraftRow[] => rows.map(row => { + const refreshable = row.status === 'metadata-error' + || (row.status === 'fallback' && row.isTorrent === true && isMagnetUrl(row.sourceUrl)); + if (!refreshable) return row; + const generation = row.generation + 1; + return { + ...row, + status: 'loading', + generation, + metadataBlockedReason: undefined, + ...(row.isTorrent === true && row.status === 'fallback' + ? { torrentCacheId: `${row.id}-${generation}` } + : {}) + }; +}); export const canSubmitMetadataRows = (rows: AddDownloadDraftRow[]): boolean => { const selectedRows = rows.filter(row => row.selected !== false); return selectedRows.length > 0 && selectedRows.every(row => row.status === 'ready' + || (row.isTorrent === true && row.status === 'fallback') || (!row.isMedia && row.status === 'metadata-error' && !row.metadataBlockedReason) ); }; @@ -604,13 +624,13 @@ export const metadataSummaryState = (rows: AddDownloadDraftRow[]): MetadataSumma const loading = selectedRows.filter(row => row.status === 'loading').length; if (loading > 0) return { type: 'loading', count: loading }; - const failed = selectedRows.filter(row => row.status === 'metadata-error').length; + const failed = selectedRows.filter(row => row.status === 'metadata-error' || row.status === 'fallback').length; const failedMedia = selectedRows.filter(row => row.status === 'metadata-error' && row.isMedia).length; const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length; const ready = selectedRows.filter(row => row.status === 'ready').length; if (blocked > 0) return { type: 'unsafe', count: blocked }; if (failedMedia > 0) return { type: 'media-error', count: failedMedia }; - if (failed === selectedRows.length) return { type: 'all-error' }; + if (failed === selectedRows.length && !selectedRows.some(row => row.status === 'fallback')) return { type: 'all-error' }; if (failed > 0) return { type: 'fallback', ready, failed }; return { type: 'ready', count: ready }; }; @@ -654,7 +674,7 @@ export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => { ); } - const failed = selectedRows.filter(row => row.status === 'metadata-error').length; + const failed = selectedRows.filter(row => row.status === 'metadata-error' || row.status === 'fallback').length; const failedMedia = selectedRows.filter(row => row.status === 'metadata-error' && row.isMedia).length; const blocked = selectedRows.filter(row => row.metadataBlockedReason === 'unsafe-url').length; const ready = selectedRows.filter(row => row.status === 'ready').length; @@ -674,7 +694,7 @@ export const metadataSummaryMessage = (rows: AddDownloadDraftRow[]): string => { () => i18n.t($ => $.addDownloads.mediaMetadataUnavailableSummaryMany, { count: failedMedia }) ); } - if (failed === selectedRows.length) { + if (failed === selectedRows.length && !selectedRows.some(row => row.status === 'fallback')) { return i18n.t($ => $.addDownloads.metadataUnavailableFallback); } if (failed > 0) { diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index 7cd58d1..80a5cb7 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -206,9 +206,9 @@ describe('allocation phase visibility', () => { expect(isAllocationPhaseVisible(false, 'downloading')).toBe(false); }); - it('uses Torrent allocation settings and excludes media and verify-only work', () => { - expect(isAllocationPhaseEligible({ isTorrent: true, torrentFileAllocation: undefined })).toBe(true); - expect(isAllocationPhaseEligible({ isTorrent: true, torrentFileAllocation: 'prealloc' })).toBe(true); + it('keeps native Torrent allocation settings out of the transient UI phase', () => { + expect(isAllocationPhaseEligible({ isTorrent: true, torrentFileAllocation: undefined })).toBe(false); + expect(isAllocationPhaseEligible({ isTorrent: true, torrentFileAllocation: 'prealloc' })).toBe(false); expect(isAllocationPhaseEligible({ isTorrent: true, torrentFileAllocation: 'none' })).toBe(false); expect(isAllocationPhaseEligible({ isTorrent: true, torrentVerifyOnly: true })).toBe(false); expect(isAllocationPhaseEligible({ isTorrent: true, isMedia: true })).toBe(false); diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index ac5b160..8a26801 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -78,15 +78,18 @@ export const isAllocationPhaseVisible = ( /** * Allocation is a transient admission phase. Normal downloads retain the * existing preallocation behavior; Torrent rows use Aria2's Torrent-specific - * allocation setting and never show the hint for verification-only work. + * allocation setting without exposing the normal-download hint, including + * for verification-only work. */ export const isAllocationPhaseEligible = ( download: Pick, ): boolean => { - if (download.isMedia === true) return false; - if (download.isTorrent !== true) return true; - return download.torrentVerifyOnly !== true - && normalizeTorrentFileAllocation(download.torrentFileAllocation) !== 'none'; + // Torrent rows retain their native file-allocation option, but Aria2's + // BitTorrent lifecycle must not be represented as Firelink's transient + // normal-download allocation phase. A zero-byte Torrent can be waiting for + // peers indefinitely, so the UI must not claim that files are being + // allocated until bytes appear. + return download.isMedia !== true && download.isTorrent !== true; }; export const DOWNLOAD_CONNECTIONS_MIN = 1; diff --git a/src/utils/torrentPresentation.test.ts b/src/utils/torrentPresentation.test.ts new file mode 100644 index 0000000..0be6dcd --- /dev/null +++ b/src/utils/torrentPresentation.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { isTorrentWaitingForPeers } from './torrentPresentation'; + +describe('Torrent waiting presentation', () => { + it('labels a zero-byte active Torrent with no connected peers or seeders', () => { + expect(isTorrentWaitingForPeers({ + isTorrent: true, + status: 'downloading', + downloadedBytes: 0, + fraction: 0, + connectedPeers: 0, + connectedSeeders: 0, + })).toBe(true); + }); + + it('does not replace native status once bytes or peers exist', () => { + expect(isTorrentWaitingForPeers({ + isTorrent: true, + status: 'downloading', + downloadedBytes: 1, + connectedPeers: 0, + connectedSeeders: 0, + })).toBe(false); + expect(isTorrentWaitingForPeers({ + isTorrent: true, + status: 'downloading', + downloadedBytes: 0, + connectedPeers: 1, + connectedSeeders: 0, + })).toBe(false); + expect(isTorrentWaitingForPeers({ + isTorrent: true, + status: 'paused', + downloadedBytes: 0, + connectedPeers: 0, + connectedSeeders: 0, + })).toBe(false); + }); + + it('does not treat missing or malformed telemetry as confirmed zero peers', () => { + expect(isTorrentWaitingForPeers({ + isTorrent: true, + status: 'downloading', + downloadedBytes: 0, + connectedPeers: 0, + connectedSeeders: undefined, + })).toBe(false); + expect(isTorrentWaitingForPeers({ + isTorrent: true, + status: 'downloading', + downloadedBytes: 0, + connectedPeers: -1, + connectedSeeders: 0, + })).toBe(false); + }); +}); diff --git a/src/utils/torrentPresentation.ts b/src/utils/torrentPresentation.ts new file mode 100644 index 0000000..fe76f16 --- /dev/null +++ b/src/utils/torrentPresentation.ts @@ -0,0 +1,46 @@ +export type TorrentPeerWaitPresentationInput = { + isTorrent?: boolean; + status: string; + downloadedBytes?: number | null; + fraction?: number | null; + connectedPeers?: number | null; + connectedSeeders?: number | null; +}; + +/** + * A Torrent with no payload or peers is still making legitimate progress + * through peer discovery. This is a presentation-only label; the persisted + * and native lifecycle status remains `downloading`. + */ +export const isTorrentWaitingForPeers = ({ + isTorrent, + status, + downloadedBytes, + fraction, + connectedPeers, + connectedSeeders, +}: TorrentPeerWaitPresentationInput): boolean => { + const isFiniteNonNegative = (value: number | null | undefined): value is number => + typeof value === 'number' && Number.isFinite(value) && value >= 0; + + // Missing telemetry is unknown, not zero. In particular, Aria2 can emit an + // early progress snapshot before numSeeders is available; labelling that + // snapshot as "Waiting for peers" would make a transient data gap look like + // a confirmed peer-discovery state. + if (!isFiniteNonNegative(downloadedBytes) + || !isFiniteNonNegative(connectedPeers) + || !isFiniteNonNegative(connectedSeeders)) { + return false; + } + if (fraction !== undefined + && fraction !== null + && (!isFiniteNonNegative(fraction) || fraction > 0)) { + return false; + } + + return isTorrent === true + && status === 'downloading' + && downloadedBytes === 0 + && connectedPeers === 0 + && connectedSeeders === 0; +};