mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-01 13:38:01 +00:00
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:
+147
-26
@@ -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
@@ -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()),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -91,6 +91,7 @@ const common = {
|
||||
staged: 'In queue',
|
||||
queued: 'Queued',
|
||||
downloading: 'Downloading',
|
||||
waitingForPeers: 'Waiting for peers',
|
||||
processing: 'Processing',
|
||||
verifying: 'Verifying',
|
||||
seeding: 'Seeding',
|
||||
|
||||
@@ -91,6 +91,7 @@ const fa = {
|
||||
staged: 'در صف',
|
||||
queued: 'در صف',
|
||||
downloading: 'در حال دانلود',
|
||||
waitingForPeers: 'در انتظار همتاها',
|
||||
processing: 'در حال پردازش',
|
||||
verifying: 'در حال بررسی صحت',
|
||||
seeding: 'در حال اشتراکگذاری',
|
||||
|
||||
@@ -91,6 +91,7 @@ const he = {
|
||||
staged: 'בתור',
|
||||
queued: 'בתור',
|
||||
downloading: 'מוריד',
|
||||
waitingForPeers: 'ממתין לעמיתים',
|
||||
processing: 'מעבד',
|
||||
verifying: 'מאמת',
|
||||
seeding: 'משתף',
|
||||
|
||||
@@ -91,6 +91,7 @@ const ru = {
|
||||
staged: 'В очереди',
|
||||
queued: 'В очереди',
|
||||
downloading: 'Загрузка',
|
||||
waitingForPeers: 'Ожидание пиров',
|
||||
processing: 'Обработка',
|
||||
verifying: 'Проверка',
|
||||
seeding: 'Раздача',
|
||||
|
||||
@@ -91,6 +91,7 @@ const uk = {
|
||||
staged: 'У черзі',
|
||||
queued: 'У черзі',
|
||||
downloading: 'Завантаження',
|
||||
waitingForPeers: 'Очікування пірів',
|
||||
processing: 'Обробка',
|
||||
verifying: 'Перевірка',
|
||||
seeding: 'Роздача',
|
||||
|
||||
@@ -91,6 +91,7 @@ const zhCN = {
|
||||
staged: '在队列中',
|
||||
queued: '已排队',
|
||||
downloading: '下载中',
|
||||
waitingForPeers: '等待节点',
|
||||
processing: '处理中',
|
||||
verifying: '校验中',
|
||||
seeding: '做种中',
|
||||
|
||||
+21
-7
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user