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
This commit is contained in:
NimBold
2026-08-24 15:51:24 +03:30
parent a6b341f9e3
commit b5ee53140d
20 changed files with 452 additions and 84 deletions
+147 -26
View File
@@ -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<i32>,
last_connection_logged_at: Option<Instant>,
started_at: Option<Instant>,
torrent_tracker_count: Option<usize>,
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);
+48 -7
View File
@@ -2410,6 +2410,51 @@ impl<R: tauri::Runtime> QueueManager<R> {
.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<usize> {
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<R: tauri::Runtime> QueueManager<R> {
}
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::<tauri::Wry>::aria2_allocation_phase_eligible(
assert!(!QueueManager::<tauri::Wry>::aria2_allocation_phase_eligible(
&SpawnPayload {
is_torrent: true,
torrent_file_allocation: Some("prealloc".to_string()),
+9
View File
@@ -697,6 +697,14 @@ fn normalized_magnet_trackers(parsed: &url::Url) -> Result<Vec<String>, 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<usize, String> {
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",
+7
View File
@@ -108,6 +108,13 @@ fn headless_queue_lifecycle_eligibility_and_retry_contracts_hold() {
..SpawnPayload::default()
})
);
assert!(
!QueueManager::<tauri::Wry>::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!(
+12 -8
View File
@@ -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 = () => {
<RefreshCw size={12} className="animate-spin" /> {item.isPlaylist ? t($ => $.addDownloads.fetchingPlaylist) : t($ => $.addDownloads.fetching)}
</div>
) : (
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)
+13 -1
View File
@@ -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<DownloadItemProps>(({
const [isActionHovered, setIsActionHovered] = React.useState(false);
const [isActionFocused, setIsActionFocused] = React.useState(false);
const [actionPosition, setActionPosition] = React.useState<React.CSSProperties | undefined>();
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<DownloadItemProps>(({
})();
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)
+21 -9
View File
@@ -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 <strong
className="properties-torrent-peer-count"
className="properties-metric-value properties-torrent-peer-count"
aria-label={t($ => $.properties.torrentConnectedPeerMetric, {
peers: peersValue,
seeders: seedersValue,
@@ -1203,7 +1215,7 @@ export const PropertiesWindowApp = () => {
<span className="properties-torrent-peer-count-connected">{seedersValue}</span>
</strong>;
})()
: <strong>{connectionPresentation.value}</strong>;
: <strong className="properties-metric-value">{connectionPresentation.value}</strong>;
const peerDetailsNotice = peerDetailsUnavailable
&& (snapshot.torrentConnectedPeers ?? 0) > 0;
const queuePlacement = formatPropertiesQueuePlacement(
@@ -1317,13 +1329,13 @@ export const PropertiesWindowApp = () => {
<span className="properties-window-progress-percent">{progressPercent}</span>
</div>
<div className="properties-window-metrics" dir="ltr">
<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>{allocationPending ? '—' : snapshot.speed || '—'}</strong></div></div>
<div className="properties-metric-card"><Timer size={14} /><div><span>{t($ => $.properties.eta)}</span><strong>{allocationPending ? '—' : snapshot.eta || '—'}</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>}
<div className="properties-metric-card"><Download size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.size)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div>
<div className="properties-metric-card"><Gauge size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.speed)}</span><strong className="properties-metric-value">{allocationPending ? '—' : snapshot.speed || '—'}</strong></div></div>
<div className="properties-metric-card"><Timer size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.eta)}</span><strong className="properties-metric-value">{allocationPending ? '—' : snapshot.eta || '—'}</strong></div></div>
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div className="properties-metric-content"><span className={`properties-metric-label ${connectionPresentation.labelKey === 'torrentPeersSeeders' ? 'properties-metric-label--wide' : ''}`}>{connectionHeaderLabel}</span>{connectionValue}</div></div>}
{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"><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"><Upload size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentUploaded)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
<div className="properties-metric-card"><Activity size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentRatio)}</span><strong className="properties-metric-value">{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div>
</>}
</div>
<div className="properties-window-destination" title={snapshot.destination || undefined}><MapPin size={13} /><span>{snapshot.destination || '—'}</span></div>
+1
View File
@@ -91,6 +91,7 @@ const common = {
staged: 'In queue',
queued: 'Queued',
downloading: 'Downloading',
waitingForPeers: 'Waiting for peers',
processing: 'Processing',
verifying: 'Verifying',
seeding: 'Seeding',
+1
View File
@@ -91,6 +91,7 @@ const fa = {
staged: 'در صف',
queued: 'در صف',
downloading: 'در حال دانلود',
waitingForPeers: 'در انتظار همتاها',
processing: 'در حال پردازش',
verifying: 'در حال بررسی صحت',
seeding: 'در حال اشتراک‌گذاری',
+1
View File
@@ -91,6 +91,7 @@ const he = {
staged: 'בתור',
queued: 'בתור',
downloading: 'מוריד',
waitingForPeers: 'ממתין לעמיתים',
processing: 'מעבד',
verifying: 'מאמת',
seeding: 'משתף',
+1
View File
@@ -91,6 +91,7 @@ const ru = {
staged: 'В очереди',
queued: 'В очереди',
downloading: 'Загрузка',
waitingForPeers: 'Ожидание пиров',
processing: 'Обработка',
verifying: 'Проверка',
seeding: 'Раздача',
+1
View File
@@ -91,6 +91,7 @@ const uk = {
staged: 'У черзі',
queued: 'У черзі',
downloading: 'Завантаження',
waitingForPeers: 'Очікування пірів',
processing: 'Обробка',
verifying: 'Перевірка',
seeding: 'Роздача',
+1
View File
@@ -91,6 +91,7 @@ const zhCN = {
staged: '在队列中',
queued: '已排队',
downloading: '下载中',
waitingForPeers: '等待节点',
processing: '处理中',
verifying: '校验中',
seeding: '做种中',
+21 -7
View File
@@ -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 {
+18 -1
View File
@@ -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(
+37 -17
View File
@@ -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) {
+3 -3
View File
@@ -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);
+8 -5
View File
@@ -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<DownloadItem, 'isMedia' | 'isTorrent' | 'torrentFileAllocation' | 'torrentVerifyOnly'>,
): 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;
+56
View File
@@ -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);
});
});
+46
View File
@@ -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;
};