diff --git a/TORRENT_FEATURES.md b/TORRENT_FEATURES.md index ce230c5..04e76f6 100644 --- a/TORRENT_FEATURES.md +++ b/TORRENT_FEATURES.md @@ -41,6 +41,11 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar `bt-force-encryption`, `bt-require-crypto`, and `bt-min-crypto-level`: disabled, required obfuscated handshake, or forced ARC4 payload encryption. The policy is persisted and reapplied when a Torrent starts or retries. +- Optional tracker timing controls through `bt-tracker-connect-timeout`, + `bt-tracker-timeout`, and `bt-tracker-interval`. Connect and request + timeouts are bounded to 1–604800 seconds; interval 0 restores Aria2's + response/progress-driven scheduling. Timing is persisted and reapplied when + a Torrent starts or retries. - Optional `bt-remove-unselected-file` cleanup after completion when a selected-file subset is configured. Firelink requires explicit confirmation, reserves the unselected paths against competing downloads, keeps those @@ -48,8 +53,8 @@ belong in the download UI. The Aria2 reference is the [1.37.0 manual](https://ar after observing Aria2's completion cleanup (or on terminal failure, cancellation, or reconfiguration). - Deterministic local Aria2 smoke coverage for metadata resolution, selected - output, piece priority, encryption policy, pause/resume, ownership, - cancellation/removal, unavailable trackers, daemon failure, and + output, piece priority, encryption policy, tracker timing, pause/resume, + ownership, cancellation/removal, unavailable trackers, daemon failure, and `bt-stop-timeout` terminal behavior; RPC-boundary coverage is separate. ## Priority tiers for remaining work @@ -65,9 +70,6 @@ No remaining Tier 0 items. per-file priority option. Firelink therefore does not pretend that `select-file` is file priority; this remains pending an engine capability or a safe product-level model. -2. **Tracker timing controls** — expose tracker connect timeout, request - timeout, and interval only when their effect on battery/network behavior is - explained and persisted. ### Tier 2 — advanced networking and daemon tuning @@ -81,4 +83,4 @@ No remaining Tier 0 items. The first implementation in this task was remote `.torrent` metadata intake; follow-up implementations add stall-timeout control, bounded peer diagnostics, persisted tracker exclusion, piece-preview priority, safe unselected-file -removal, and the validated encryption policy. +removal, the validated encryption policy, and tracker timing controls. diff --git a/scripts/smoke-torrent.js b/scripts/smoke-torrent.js index dc3f689..67c04ef 100644 --- a/scripts/smoke-torrent.js +++ b/scripts/smoke-torrent.js @@ -716,6 +716,9 @@ async function main() { { dir: finalDir, 'bt-tracker': `http://127.0.0.1:${trackerPort}/announce`, + 'bt-tracker-connect-timeout': '5', + 'bt-tracker-timeout': '7', + 'bt-tracker-interval': '2', 'select-file': '1', 'bt-prioritize-piece': 'head=32K,tail=16K', 'index-out': indexOut, @@ -730,6 +733,9 @@ async function main() { finalOptions['bt-prioritize-piece'] === 'head=32K,tail=16K', `Aria2 did not retain the piece-priority option: ${JSON.stringify(finalOptions['bt-prioritize-piece'])}`, ); + assert(finalOptions['bt-tracker-connect-timeout'] === '5', 'Aria2 did not retain tracker connect timeout'); + assert(finalOptions['bt-tracker-timeout'] === '7', 'Aria2 did not retain tracker request timeout'); + assert(finalOptions['bt-tracker-interval'] === '2', 'Aria2 did not retain tracker interval'); await rpc(client.rpcPort, client.secret, 'aria2.forcePause', [finalGid]); await waitForStatus(client, finalGid, 'paused', 10000); await rpc(client.rpcPort, client.secret, 'aria2.unpause', [finalGid]); diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index e849bbd..7243267 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -202,6 +202,15 @@ pub struct DownloadItem { pub torrent_exclude_trackers: Option, #[serde(default)] #[ts(optional)] + pub torrent_tracker_connect_timeout: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_tracker_timeout: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_tracker_interval: Option, + #[serde(default)] + #[ts(optional)] pub torrent_stop_timeout: Option, #[serde(default)] #[ts(optional)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fbb5bf6..aa462a8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5802,6 +5802,13 @@ async fn validate_torrent_enqueue( item.torrent_trackers = queue::normalize_torrent_trackers(item.torrent_trackers.as_deref())?; item.torrent_exclude_trackers = queue::normalize_torrent_exclude_trackers(item.torrent_exclude_trackers.as_deref())?; + item.torrent_tracker_connect_timeout = queue::normalize_torrent_tracker_connect_timeout( + item.torrent_tracker_connect_timeout, + )?; + item.torrent_tracker_timeout = + queue::normalize_torrent_tracker_request_timeout(item.torrent_tracker_timeout)?; + item.torrent_tracker_interval = + queue::normalize_torrent_tracker_interval(item.torrent_tracker_interval)?; item.torrent_stop_timeout = queue::normalize_torrent_stop_timeout(item.torrent_stop_timeout)?; item.torrent_prioritize_piece = queue::normalize_torrent_prioritize_piece( item.torrent_prioritize_piece.as_deref(), diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs index 584d02b..8b66023 100644 --- a/src-tauri/src/queue.rs +++ b/src-tauri/src/queue.rs @@ -22,6 +22,8 @@ pub const MEDIA_RUN_CANCELLED: &str = "__firelink_media_run_cancelled__"; pub const DOWNLOAD_CONNECTIONS_MIN: i32 = 1; pub const DOWNLOAD_CONNECTIONS_MAX: i32 = 16; pub const MAX_TORRENT_PIECE_PRIORITY_SIZE_MIB: u64 = 1024; +pub const MAX_TORRENT_TRACKER_TIMEOUT: u32 = 604_800; +pub const MAX_TORRENT_TRACKER_INTERVAL: u32 = 604_800; pub fn clamp_download_connections(connections: i32) -> i32 { connections.clamp(DOWNLOAD_CONNECTIONS_MIN, DOWNLOAD_CONNECTIONS_MAX) @@ -218,6 +220,9 @@ pub struct SpawnPayload { pub torrent_check_integrity: bool, pub torrent_trackers: Option, pub torrent_exclude_trackers: Option, + pub torrent_tracker_connect_timeout: Option, + pub torrent_tracker_timeout: Option, + pub torrent_tracker_interval: Option, pub torrent_stop_timeout: Option, pub torrent_prioritize_piece: Option, pub torrent_remove_unselected_file: bool, @@ -3507,6 +3512,44 @@ pub(crate) fn normalize_torrent_exclude_trackers( normalize_torrent_tracker_list(value, true) } +fn normalize_torrent_tracker_timeout(value: Option, field: &str) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + if !(1..=MAX_TORRENT_TRACKER_TIMEOUT).contains(&value) { + return Err(format!( + "torrent {field} must be between 1 and {MAX_TORRENT_TRACKER_TIMEOUT} seconds" + )); + } + Ok(Some(value)) +} + +pub(crate) fn normalize_torrent_tracker_connect_timeout( + value: Option, +) -> Result, String> { + normalize_torrent_tracker_timeout(value, "tracker connect timeout") +} + +pub(crate) fn normalize_torrent_tracker_request_timeout( + value: Option, +) -> Result, String> { + normalize_torrent_tracker_timeout(value, "tracker timeout") +} + +pub(crate) fn normalize_torrent_tracker_interval( + value: Option, +) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + if value > MAX_TORRENT_TRACKER_INTERVAL { + return Err(format!( + "torrent tracker interval must be between 0 and {MAX_TORRENT_TRACKER_INTERVAL} seconds" + )); + } + Ok(Some(value)) +} + pub(crate) fn normalize_torrent_encryption_policy( value: Option<&str>, ) -> Result, String> { @@ -3614,6 +3657,28 @@ fn apply_aria2_torrent_options( { options.insert("bt-exclude-tracker".to_string(), serde_json::json!(trackers)); } + if let Some(timeout) = normalize_torrent_tracker_connect_timeout( + payload.torrent_tracker_connect_timeout, + )? { + options.insert( + "bt-tracker-connect-timeout".to_string(), + serde_json::json!(timeout.to_string()), + ); + } + if let Some(timeout) = + normalize_torrent_tracker_request_timeout(payload.torrent_tracker_timeout)? + { + options.insert( + "bt-tracker-timeout".to_string(), + serde_json::json!(timeout.to_string()), + ); + } + if let Some(interval) = normalize_torrent_tracker_interval(payload.torrent_tracker_interval)? { + options.insert( + "bt-tracker-interval".to_string(), + serde_json::json!(interval.to_string()), + ); + } if let Some(stop_timeout) = normalize_torrent_stop_timeout(payload.torrent_stop_timeout)? { options.insert( "bt-stop-timeout".to_string(), @@ -4257,6 +4322,15 @@ pub struct EnqueueItem { pub torrent_exclude_trackers: Option, #[serde(default)] #[ts(optional)] + pub torrent_tracker_connect_timeout: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_tracker_timeout: Option, + #[serde(default)] + #[ts(optional)] + pub torrent_tracker_interval: Option, + #[serde(default)] + #[ts(optional)] pub torrent_stop_timeout: Option, #[serde(default)] #[ts(optional)] @@ -4319,6 +4393,9 @@ impl EnqueueItem { torrent_check_integrity: self.torrent_check_integrity.unwrap_or(false), torrent_trackers: self.torrent_trackers, torrent_exclude_trackers: self.torrent_exclude_trackers, + torrent_tracker_connect_timeout: self.torrent_tracker_connect_timeout, + torrent_tracker_timeout: self.torrent_tracker_timeout, + torrent_tracker_interval: self.torrent_tracker_interval, torrent_stop_timeout: self.torrent_stop_timeout, torrent_prioritize_piece: self.torrent_prioritize_piece, torrent_remove_unselected_file: self @@ -4619,6 +4696,28 @@ mod tests { ); } + #[test] + fn enqueue_item_preserves_torrent_tracker_timing() { + let item: EnqueueItem = serde_json::from_value(serde_json::json!({ + "id": "torrent-tracker-timing", + "queue_id": "main", + "url": "magnet:?xt=urn:btih:0123456789012345678901234567890123456789", + "destination": "/tmp/downloads", + "filename": "payload", + "is_media": false, + "is_torrent": true, + "torrent_tracker_connect_timeout": 11, + "torrent_tracker_timeout": 22, + "torrent_tracker_interval": 33 + })) + .expect("frontend enqueue payload should deserialize"); + + let payload = item.into_task().payload; + assert_eq!(payload.torrent_tracker_connect_timeout, Some(11)); + assert_eq!(payload.torrent_tracker_timeout, Some(22)); + assert_eq!(payload.torrent_tracker_interval, Some(33)); + } + #[test] fn torrent_trackers_are_normalized_and_deduplicated() { assert_eq!( @@ -4676,6 +4775,62 @@ mod tests { } } + #[test] + fn torrent_tracker_timing_is_bounded_and_preserves_aria2_defaults() { + assert_eq!(normalize_torrent_tracker_connect_timeout(None).unwrap(), None); + assert_eq!( + normalize_torrent_tracker_connect_timeout(Some(1)).unwrap(), + Some(1) + ); + assert_eq!( + normalize_torrent_tracker_request_timeout(Some(MAX_TORRENT_TRACKER_TIMEOUT)).unwrap(), + Some(MAX_TORRENT_TRACKER_TIMEOUT) + ); + assert_eq!(normalize_torrent_tracker_interval(Some(0)).unwrap(), Some(0)); + assert!(normalize_torrent_tracker_connect_timeout(Some(0)).is_err()); + assert!(normalize_torrent_tracker_request_timeout(Some(0)).is_err()); + assert!(normalize_torrent_tracker_interval(Some(MAX_TORRENT_TRACKER_INTERVAL + 1)).is_err()); + + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + is_torrent: true, + torrent_tracker_connect_timeout: Some(11), + torrent_tracker_timeout: Some(22), + torrent_tracker_interval: Some(33), + ..Default::default() + }; + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + assert_eq!( + options.get("bt-tracker-connect-timeout"), + Some(&serde_json::json!("11")) + ); + assert_eq!( + options.get("bt-tracker-timeout"), + Some(&serde_json::json!("22")) + ); + assert_eq!( + options.get("bt-tracker-interval"), + Some(&serde_json::json!("33")) + ); + } + + #[test] + fn torrent_tracker_timing_is_not_applied_to_non_torrent_payloads() { + let mut options = serde_json::Map::new(); + let payload = SpawnPayload { + torrent_tracker_connect_timeout: Some(11), + torrent_tracker_timeout: Some(22), + torrent_tracker_interval: Some(33), + ..Default::default() + }; + + apply_aria2_torrent_options(&mut options, &payload).unwrap(); + + assert!(!options.contains_key("bt-tracker-connect-timeout")); + assert!(!options.contains_key("bt-tracker-timeout")); + assert!(!options.contains_key("bt-tracker-interval")); + } + #[test] fn torrent_trackers_are_emitted_as_the_aria2_tracker_option() { let mut options = serde_json::Map::new(); diff --git a/src/bindings/DownloadItem.ts b/src/bindings/DownloadItem.ts index 7db14a7..1a9e4e7 100644 --- a/src/bindings/DownloadItem.ts +++ b/src/bindings/DownloadItem.ts @@ -2,4 +2,4 @@ import type { DownloadCategory } from "./DownloadCategory"; import type { DownloadStatus } from "./DownloadStatus"; -export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, }; +export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, mediaQuality?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, isTorrent?: boolean, torrentPath?: string, torrentFileIndices?: Array, torrentInfoHash?: string, torrentSeedTime?: number, torrentSeedRatio?: number, torrentUploadLimit?: string, torrentMaxPeers?: number, torrentPeerSpeedLimit?: string, torrentCheckIntegrity?: boolean, torrentTrackers?: string, torrentExcludeTrackers?: string, torrentTrackerConnectTimeout?: number, torrentTrackerTimeout?: number, torrentTrackerInterval?: number, torrentStopTimeout?: number, torrentPrioritizePiece?: string, torrentRemoveUnselectedFile?: boolean, torrentEncryptionPolicy?: string, }; diff --git a/src/bindings/EnqueueItem.ts b/src/bindings/EnqueueItem.ts index 4932dff..f3b0465 100644 --- a/src/bindings/EnqueueItem.ts +++ b/src/bindings/EnqueueItem.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, lifecycle_generation?: string, }; +export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, is_torrent?: boolean, torrent_path?: string, torrent_file_indices?: Array, torrent_info_hash?: string, torrent_seed_time?: number, torrent_seed_ratio?: number, torrent_upload_limit?: string, torrent_max_peers?: number, torrent_peer_speed_limit?: string, torrent_check_integrity?: boolean, torrent_trackers?: string, torrent_exclude_trackers?: string, torrent_tracker_connect_timeout?: number, torrent_tracker_timeout?: number, torrent_tracker_interval?: number, torrent_stop_timeout?: number, torrent_prioritize_piece?: string, torrent_remove_unselected_file?: boolean, torrent_encryption_policy?: string, lifecycle_generation?: string, }; diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 7f361c2..29fecea 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -13,7 +13,7 @@ import { FolderPlus, Save, Settings, Shield, RefreshCw, FileText, HardDrive, Dat import { open } from '@tauri-apps/plugin-dialog'; import { invokeCommand as invoke } from '../ipc'; import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal'; -import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy } from '../utils/downloads'; import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata'; import { expandTilde, @@ -237,6 +237,9 @@ export const AddDownloadsModal = () => { const [torrentEncryptionPolicy, setTorrentEncryptionPolicy] = useState(TORRENT_ENCRYPTION_POLICY_DISABLED); const [torrentTrackers, setTorrentTrackers] = useState(''); const [torrentExcludeTrackers, setTorrentExcludeTrackers] = useState(''); + const [torrentTrackerConnectTimeout, setTorrentTrackerConnectTimeout] = useState(''); + const [torrentTrackerTimeout, setTorrentTrackerTimeout] = useState(''); + const [torrentTrackerInterval, setTorrentTrackerInterval] = useState('0'); const [torrentStopTimeout, setTorrentStopTimeout] = useState('0'); const [torrentPrioritizePiece, setTorrentPrioritizePiece] = useState(''); const [freeSpace, setFreeSpace] = useState('Unknown'); @@ -382,6 +385,9 @@ export const AddDownloadsModal = () => { setTorrentCheckIntegrity(false); setTorrentTrackers(''); setTorrentExcludeTrackers(''); + setTorrentTrackerConnectTimeout(''); + setTorrentTrackerTimeout(''); + setTorrentTrackerInterval('0'); setTorrentStopTimeout('0'); setUseAuth(false); setUsername(''); @@ -987,6 +993,18 @@ export const AddDownloadsModal = () => { addToast({ message: t($ => $.addDownloads.torrentExcludeTrackersInvalid), variant: 'error', isActionable: true }); return; } + if (hasSelectedTorrent && torrentTrackerConnectTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerConnectTimeout)) { + addToast({ message: t($ => $.addDownloads.torrentTrackerTimeoutInvalid), variant: 'error', isActionable: true }); + return; + } + if (hasSelectedTorrent && torrentTrackerTimeout.trim() && !normalizeTorrentTrackerTimeout(torrentTrackerTimeout)) { + addToast({ message: t($ => $.addDownloads.torrentTrackerTimeoutInvalid), variant: 'error', isActionable: true }); + return; + } + if (hasSelectedTorrent && torrentTrackerInterval.trim() && normalizeTorrentTrackerInterval(torrentTrackerInterval) === undefined) { + addToast({ message: t($ => $.addDownloads.torrentTrackerIntervalInvalid), variant: 'error', isActionable: true }); + return; + } if (hasSelectedTorrent && torrentPrioritizePiece.trim() && !normalizeTorrentPrioritizePiece(torrentPrioritizePiece)) { addToast({ message: t($ => $.addDownloads.torrentPrioritizePieceInvalid), variant: 'error', isActionable: true }); return; @@ -1501,6 +1519,15 @@ export const AddDownloadsModal = () => { : undefined, torrentTrackers: item.isTorrent ? torrentTrackers.trim() || undefined : undefined, torrentExcludeTrackers: item.isTorrent ? torrentExcludeTrackers.trim() || undefined : undefined, + torrentTrackerConnectTimeout: item.isTorrent && torrentTrackerConnectTimeout.trim() + ? Number(torrentTrackerConnectTimeout) + : undefined, + torrentTrackerTimeout: item.isTorrent && torrentTrackerTimeout.trim() + ? Number(torrentTrackerTimeout) + : undefined, + torrentTrackerInterval: item.isTorrent && torrentTrackerInterval.trim() + ? Number(torrentTrackerInterval) + : undefined, torrentStopTimeout: item.isTorrent && torrentStopTimeout.trim() ? Number(torrentStopTimeout) : undefined, torrentPrioritizePiece: item.isTorrent ? normalizeTorrentPrioritizePiece(torrentPrioritizePiece) || undefined : undefined, size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined), @@ -2242,6 +2269,64 @@ export const AddDownloadsModal = () => { {t($ => $.addDownloads.torrentExcludeTrackersHint)}

+
+ +
+ setTorrentTrackerConnectTimeout(event.currentTarget.value)} + placeholder="60" + className="app-control w-24 px-2 py-1 text-end font-mono" + aria-describedby="torrent-tracker-timing-hint" + /> + {t($ => $.addDownloads.seconds)} +
+ +
+ setTorrentTrackerTimeout(event.currentTarget.value)} + placeholder="60" + className="app-control w-24 px-2 py-1 text-end font-mono" + aria-describedby="torrent-tracker-timing-hint" + /> + {t($ => $.addDownloads.seconds)} +
+ +
+ setTorrentTrackerInterval(event.currentTarget.value)} + className="app-control w-24 px-2 py-1 text-end font-mono" + aria-describedby="torrent-tracker-timing-hint" + /> + {t($ => $.addDownloads.seconds)} +
+

+ {t($ => $.addDownloads.torrentTrackerTimingHint)} +

+
+ +
+
+ setTorrentTrackerConnectTimeout(event.currentTarget.value)} + placeholder="60" + disabled={transferLocked} + aria-describedby="torrent-tracker-timing-properties-hint" + className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50" + /> + {t($ => $.properties.seconds)} +
+
+ +
+
+ setTorrentTrackerTimeout(event.currentTarget.value)} + placeholder="60" + disabled={transferLocked} + aria-describedby="torrent-tracker-timing-properties-hint" + className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50" + /> + {t($ => $.properties.seconds)} +
+
+ +
+
+ setTorrentTrackerInterval(event.currentTarget.value)} + disabled={transferLocked} + aria-describedby="torrent-tracker-timing-properties-hint" + className="app-control w-24 px-2.5 py-1.5 text-end text-xs font-mono disabled:opacity-50" + /> + {t($ => $.properties.seconds)} +
+

+ {t($ => $.properties.torrentTrackerTimingHint)} +

+
diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index 336ba21..04347f4 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -242,6 +242,12 @@ const common = { torrentExcludeTrackers: 'Excluded Torrent trackers', torrentExcludeTrackersHint: 'One HTTP, HTTPS, or UDP tracker per line, or * to exclude all announce URLs. Credentials are not allowed; DHT and PEX settings are unchanged.', torrentExcludeTrackersInvalid: 'Excluded Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials, or *.', + torrentTrackerConnectTimeout: 'Tracker connect timeout', + torrentTrackerTimeout: 'Tracker request timeout', + torrentTrackerInterval: 'Tracker interval', + torrentTrackerTimingHint: 'Connect timeout covers establishing a tracker connection; request timeout covers the response afterward. Blank values keep Aria2’s 60-second defaults, and interval 0 follows tracker response and download progress.', + torrentTrackerTimeoutInvalid: 'Tracker timeout must be a whole number from 1 to 604800 seconds', + torrentTrackerIntervalInvalid: 'Tracker interval must be a whole number from 0 to 604800 seconds', torrentVerifyIntegrity: 'Verify Torrent integrity', torrentVerifyIntegrityHint: 'Applied when this Torrent starts or retries. It may recheck pieces and download damaged data; active transfers cannot change it.', torrentMaxPeers: 'Maximum Torrent peers', @@ -527,6 +533,12 @@ const common = { torrentExcludeTrackers: 'Excluded Torrent trackers', torrentExcludeTrackersHint: 'Saved with this Torrent and applied on its next start or retry. * excludes all announce URLs; DHT and PEX settings are unchanged.', torrentExcludeTrackersInvalid: 'Excluded Torrent tracker list is invalid. Use HTTP, HTTPS, or UDP tracker URLs without credentials, or *.', + torrentTrackerConnectTimeout: 'Tracker connect timeout', + torrentTrackerTimeout: 'Tracker request timeout', + torrentTrackerInterval: 'Tracker interval', + torrentTrackerTimingHint: 'Saved with this Torrent and applied on its next start or retry. Connect timeout covers establishing the connection; request timeout covers the response afterward. Blank values keep Aria2’s 60-second defaults, and interval 0 follows tracker response and download progress.', + torrentTrackerTimeoutInvalid: 'Tracker timeout must be a whole number from 1 to 604800 seconds', + torrentTrackerIntervalInvalid: 'Tracker interval must be a whole number from 0 to 604800 seconds', torrentVerifyIntegrity: 'Verify Torrent integrity', torrentVerifyIntegrityHint: 'Recheck piece hashes when starting or retrying; damaged pieces may be downloaded again.', torrentMaxPeers: 'Maximum Torrent peers', diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index 63f7246..5b8edea 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -242,6 +242,12 @@ const fa = { torrentExcludeTrackers: 'Trackerهای مستثناشده تورنت', torrentExcludeTrackersHint: 'در هر خط یک Tracker از نوع HTTP، HTTPS یا UDP، یا * برای حذف همه آدرس‌های announce وارد کنید. اطلاعات ورود مجاز نیست؛ تنظیمات DHT و PEX تغییر نمی‌کند.', torrentExcludeTrackersInvalid: 'فهرست Trackerهای مستثناشده نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود، یا * استفاده کنید.', + torrentTrackerConnectTimeout: 'مهلت اتصال به Tracker', + torrentTrackerTimeout: 'مهلت درخواست Tracker', + torrentTrackerInterval: 'فاصله درخواست Tracker', + torrentTrackerTimingHint: 'مهلت اتصال برای برقراری اتصال به Tracker و مهلت درخواست برای پاسخ پس از آن است. مقدار خالی پیش‌فرض ۶۰ ثانیه‌ای آریا۲ را نگه می‌دارد و فاصله ۰ از پاسخ Tracker و پیشرفت دانلود پیروی می‌کند.', + torrentTrackerTimeoutInvalid: 'مهلت Tracker باید عددی صحیح بین ۱ تا ۶۰۴۸۰۰ ثانیه باشد', + torrentTrackerIntervalInvalid: 'فاصله Tracker باید عددی صحیح بین ۰ تا ۶۰۴۸۰۰ ثانیه باشد', torrentVerifyIntegrity: 'بررسی صحت تورنت', torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد این تورنت اعمال می‌شود. ممکن است قطعه‌ها دوباره بررسی و داده‌های خراب دوباره دانلود شوند؛ در انتقال فعال قابل تغییر نیست.', torrentMaxPeers: 'حداکثر همتاهای تورنت', @@ -527,6 +533,12 @@ const fa = { torrentExcludeTrackers: 'Trackerهای مستثناشده تورنت', torrentExcludeTrackersHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود. * همه آدرس‌های announce را حذف می‌کند؛ تنظیمات DHT و PEX تغییر نمی‌کند.', torrentExcludeTrackersInvalid: 'فهرست Trackerهای مستثناشده نامعتبر است. از آدرس HTTP، HTTPS یا UDP بدون اطلاعات ورود، یا * استفاده کنید.', + torrentTrackerConnectTimeout: 'مهلت اتصال به Tracker', + torrentTrackerTimeout: 'مهلت درخواست Tracker', + torrentTrackerInterval: 'فاصله درخواست Tracker', + torrentTrackerTimingHint: 'با این تورنت ذخیره و در شروع یا تلاش مجدد بعدی اعمال می‌شود. مهلت اتصال برای برقراری اتصال و مهلت درخواست برای پاسخ پس از آن است؛ مقدار خالی پیش‌فرض ۶۰ ثانیه‌ای آریا۲ را نگه می‌دارد و فاصله ۰ از پاسخ Tracker و پیشرفت دانلود پیروی می‌کند.', + torrentTrackerTimeoutInvalid: 'مهلت Tracker باید عددی صحیح بین ۱ تا ۶۰۴۸۰۰ ثانیه باشد', + torrentTrackerIntervalInvalid: 'فاصله Tracker باید عددی صحیح بین ۰ تا ۶۰۴۸۰۰ ثانیه باشد', torrentVerifyIntegrity: 'بررسی صحت تورنت', torrentVerifyIntegrityHint: 'هنگام شروع یا تلاش مجدد، هش قطعه‌ها را بررسی می‌کند؛ قطعه‌های خراب ممکن است دوباره دانلود شوند.', torrentMaxPeers: 'حداکثر همتاهای تورنت', diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index ac2dc80..8b43531 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -242,6 +242,12 @@ const he = { torrentExcludeTrackers: 'עוקבי טורנט להחרגה', torrentExcludeTrackersHint: 'עוקב HTTP, HTTPS או UDP אחד בכל שורה, או * כדי להחריג את כל כתובות ההכרזה. פרטי התחברות אינם מותרים; הגדרות DHT ו-PEX לא ישתנו.', torrentExcludeTrackersInvalid: 'רשימת עוקבי הטורנט להחרגה אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות, או ב-*.', + torrentTrackerConnectTimeout: 'זמן קצוב לחיבור ל-Tracker', + torrentTrackerTimeout: 'זמן קצוב לבקשת Tracker', + torrentTrackerInterval: 'מרווח בין בקשות Tracker', + torrentTrackerTimingHint: 'זמן קצוב לחיבור חל על יצירת החיבור ל-Tracker, וזמן קצוב לבקשה חל על התגובה שלאחר מכן. ערכים ריקים שומרים על ברירת המחדל של Aria2, 60 שניות; מרווח 0 עוקב אחר תגובת ה-Tracker והתקדמות ההורדה.', + torrentTrackerTimeoutInvalid: 'זמן הקצוב ל-Tracker חייב להיות מספר שלם בין 1 ל-604800 שניות', + torrentTrackerIntervalInvalid: 'מרווח ה-Tracker חייב להיות מספר שלם בין 0 ל-604800 שניות', torrentVerifyIntegrity: 'אימות תקינות הטורנט', torrentVerifyIntegrityHint: 'מוחל כשהטורנט מתחיל או מנסה שוב. ייתכן שהחלקים ייבדקו מחדש ונתונים פגומים יורדו שוב; אי אפשר לשנות זאת בהעברה פעילה.', torrentMaxPeers: 'מספר העמיתים המרבי בטורנט', @@ -527,6 +533,12 @@ const he = { torrentExcludeTrackers: 'עוקבי טורנט להחרגה', torrentExcludeTrackersHint: 'נשמרים עם הטורנט ומוחלים בהפעלה או בניסיון החוזר הבא. * מחריג את כל כתובות ההכרזה; הגדרות DHT ו-PEX לא ישתנו.', torrentExcludeTrackersInvalid: 'רשימת עוקבי הטורנט להחרגה אינה תקינה. השתמש בכתובות HTTP, HTTPS או UDP ללא פרטי התחברות, או ב-*.', + torrentTrackerConnectTimeout: 'זמן קצוב לחיבור ל-Tracker', + torrentTrackerTimeout: 'זמן קצוב לבקשת Tracker', + torrentTrackerInterval: 'מרווח בין בקשות Tracker', + torrentTrackerTimingHint: 'נשמר עם ה-Torrent ומוחל בהפעלה או בניסיון חוזר. זמן קצוב לחיבור חל על יצירת החיבור, וזמן קצוב לבקשה חל על התגובה שלאחר מכן; ערכים ריקים שומרים על ברירת המחדל של Aria2, 60 שניות, ומרווח 0 עוקב אחר תגובת ה-Tracker והתקדמות ההורדה.', + torrentTrackerTimeoutInvalid: 'זמן הקצוב ל-Tracker חייב להיות מספר שלם בין 1 ל-604800 שניות', + torrentTrackerIntervalInvalid: 'מרווח ה-Tracker חייב להיות מספר שלם בין 0 ל-604800 שניות', torrentVerifyIntegrity: 'אימות תקינות הטורנט', torrentVerifyIntegrityHint: 'בדיקת גיבובי החלקים בעת התחלה או ניסיון חוזר; חלקים פגומים עשויים להיות מורדים מחדש.', torrentMaxPeers: 'מספר העמיתים המרבי בטורנט', diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index 35aeee3..9df0dc5 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -242,6 +242,12 @@ const ru = { torrentExcludeTrackers: 'Исключаемые трекеры торрента', torrentExcludeTrackersHint: 'По одному HTTP-, HTTPS- или UDP-трекеру в строке или * для исключения всех announce-адресов. Данные для входа не допускаются; настройки DHT и PEX не изменяются.', torrentExcludeTrackersInvalid: 'Список исключаемых трекеров недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа либо *.', + torrentTrackerConnectTimeout: 'Тайм-аут подключения к трекеру', + torrentTrackerTimeout: 'Тайм-аут запроса к трекеру', + torrentTrackerInterval: 'Интервал запросов к трекеру', + torrentTrackerTimingHint: 'Тайм-аут подключения действует при установлении соединения с трекером, а тайм-аут запроса — после этого для ответа. Пустые значения сохраняют стандартные 60 секунд Aria2; интервал 0 следует ответу трекера и прогрессу загрузки.', + torrentTrackerTimeoutInvalid: 'Тайм-аут трекера должен быть целым числом от 1 до 604800 секунд', + torrentTrackerIntervalInvalid: 'Интервал трекера должен быть целым числом от 0 до 604800 секунд', torrentVerifyIntegrity: 'Проверять целостность торрента', torrentVerifyIntegrityHint: 'Применяется при запуске или повторной попытке. Может повторно проверить части и скачать повреждённые данные; во время активной передачи изменить нельзя.', torrentMaxPeers: 'Максимум пиров торрента', @@ -527,6 +533,12 @@ const ru = { torrentExcludeTrackers: 'Исключаемые трекеры торрента', torrentExcludeTrackersHint: 'Сохраняется вместе с торрентом и применяется при следующем запуске или повторной попытке. * исключает все announce-адреса; настройки DHT и PEX не изменяются.', torrentExcludeTrackersInvalid: 'Список исключаемых трекеров недействителен. Используйте URL HTTP, HTTPS или UDP без данных для входа либо *.', + torrentTrackerConnectTimeout: 'Тайм-аут подключения к трекеру', + torrentTrackerTimeout: 'Тайм-аут запроса к трекеру', + torrentTrackerInterval: 'Интервал запросов к трекеру', + torrentTrackerTimingHint: 'Сохраняется вместе с Torrent и применяется при следующем запуске или повторной попытке. Тайм-аут подключения действует при установлении соединения, а тайм-аут запроса — для ответа после этого; пустые значения сохраняют стандартные 60 секунд Aria2, а интервал 0 следует ответу трекера и прогрессу загрузки.', + torrentTrackerTimeoutInvalid: 'Тайм-аут трекера должен быть целым числом от 1 до 604800 секунд', + torrentTrackerIntervalInvalid: 'Интервал трекера должен быть целым числом от 0 до 604800 секунд', torrentVerifyIntegrity: 'Проверять целостность торрента', torrentVerifyIntegrityHint: 'Проверка хешей частей при запуске или повторной попытке; повреждённые части могут быть загружены заново.', torrentMaxPeers: 'Максимум пиров торрента', diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index ed6a37c..fce4942 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -242,6 +242,12 @@ const uk = { torrentExcludeTrackers: 'Виключені трекери торрента', torrentExcludeTrackersHint: 'Один HTTP-, HTTPS- або UDP-трекер у рядку або * для виключення всіх announce-адрес. Дані для входу не дозволені; налаштування DHT і PEX не змінюються.', torrentExcludeTrackersInvalid: 'Список виключених трекерів недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу або *.', + torrentTrackerConnectTimeout: 'Час очікування підключення до трекера', + torrentTrackerTimeout: 'Час очікування запиту до трекера', + torrentTrackerInterval: 'Інтервал запитів до трекера', + torrentTrackerTimingHint: 'Час очікування підключення діє під час встановлення з’єднання з трекером, а час очікування запиту — для відповіді після цього. Порожні значення зберігають стандартні 60 секунд Aria2; інтервал 0 використовує відповідь трекера та прогрес завантаження.', + torrentTrackerTimeoutInvalid: 'Час очікування трекера має бути цілим числом від 1 до 604800 секунд', + torrentTrackerIntervalInvalid: 'Інтервал трекера має бути цілим числом від 0 до 604800 секунд', torrentVerifyIntegrity: 'Перевіряти цілісність торрента', torrentVerifyIntegrityHint: 'Застосовується під час запуску або повторної спроби. Частини можуть перевірятися повторно, а пошкоджені дані — завантажуватися знову; під час активної передачі змінити не можна.', torrentMaxPeers: 'Максимум пірів торрента', @@ -527,6 +533,12 @@ const uk = { torrentExcludeTrackers: 'Виключені трекери торрента', torrentExcludeTrackersHint: 'Зберігається разом із торрентом і застосовується під час наступного запуску або повторної спроби. * виключає всі announce-адреси; налаштування DHT і PEX не змінюються.', torrentExcludeTrackersInvalid: 'Список виключених трекерів недійсний. Використовуйте URL HTTP, HTTPS або UDP без даних для входу або *.', + torrentTrackerConnectTimeout: 'Час очікування підключення до трекера', + torrentTrackerTimeout: 'Час очікування запиту до трекера', + torrentTrackerInterval: 'Інтервал запитів до трекера', + torrentTrackerTimingHint: 'Зберігається разом із Torrent і застосовується під час наступного запуску або повторної спроби. Час очікування підключення діє під час встановлення з’єднання, а час очікування запиту — для відповіді після цього; порожні значення зберігають стандартні 60 секунд Aria2, а інтервал 0 використовує відповідь трекера та прогрес завантаження.', + torrentTrackerTimeoutInvalid: 'Час очікування трекера має бути цілим числом від 1 до 604800 секунд', + torrentTrackerIntervalInvalid: 'Інтервал трекера має бути цілим числом від 0 до 604800 секунд', torrentVerifyIntegrity: 'Перевіряти цілісність торрента', torrentVerifyIntegrityHint: 'Перевіряє хеші частин під час запуску або повторної спроби; пошкоджені частини можуть завантажуватися знову.', torrentMaxPeers: 'Максимум пірів торрента', diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index e091908..b5e503e 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -242,6 +242,12 @@ const zhCN = { torrentExcludeTrackers: '排除的 Torrent Tracker', torrentExcludeTrackersHint: '每行一个 HTTP、HTTPS 或 UDP Tracker,或使用 * 排除所有 announce 地址。不允许填写凭据;不会更改 DHT 和 PEX 设置。', torrentExcludeTrackersInvalid: '排除的 Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址,或使用 *。', + torrentTrackerConnectTimeout: 'Tracker 连接超时', + torrentTrackerTimeout: 'Tracker 请求超时', + torrentTrackerInterval: 'Tracker 请求间隔', + torrentTrackerTimingHint: '连接超时用于建立 Tracker 连接,请求超时用于连接后的响应。留空会保留 Aria2 的 60 秒默认值;间隔 0 会遵循 Tracker 响应和下载进度。', + torrentTrackerTimeoutInvalid: 'Tracker 超时必须是 1 到 604800 秒之间的整数', + torrentTrackerIntervalInvalid: 'Tracker 间隔必须是 0 到 604800 秒之间的整数', torrentVerifyIntegrity: '验证 Torrent 完整性', torrentVerifyIntegrityHint: '在 Torrent 启动或重试时应用。可能会重新检查分片并重新下载损坏的数据;活动传输期间无法更改。', torrentMaxPeers: 'Torrent 最大对等节点数', @@ -527,6 +533,12 @@ const zhCN = { torrentExcludeTrackers: '排除的 Torrent Tracker', torrentExcludeTrackersHint: '随该 Torrent 保存,并在下次启动或重试时应用。* 会排除所有 announce 地址;不会更改 DHT 和 PEX 设置。', torrentExcludeTrackersInvalid: '排除的 Torrent Tracker 列表无效。请使用不含凭据的 HTTP、HTTPS 或 UDP 地址,或使用 *。', + torrentTrackerConnectTimeout: 'Tracker 连接超时', + torrentTrackerTimeout: 'Tracker 请求超时', + torrentTrackerInterval: 'Tracker 请求间隔', + torrentTrackerTimingHint: '随 Torrent 保存,并在下次启动或重试时应用。连接超时用于建立连接,请求超时用于之后的响应;留空会保留 Aria2 的 60 秒默认值,间隔 0 会遵循 Tracker 响应和下载进度。', + torrentTrackerTimeoutInvalid: 'Tracker 超时必须是 1 到 604800 秒之间的整数', + torrentTrackerIntervalInvalid: 'Tracker 间隔必须是 0 到 604800 秒之间的整数', torrentVerifyIntegrity: '验证 Torrent 完整性', torrentVerifyIntegrityHint: '启动或重试时重新检查分片哈希;损坏的分片可能会再次下载。', torrentMaxPeers: 'Torrent 最大对等节点数', diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 357eea5..666c288 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -856,6 +856,9 @@ describe('useDownloadStore', () => { torrentCheckIntegrity: 'yes' as unknown as boolean, torrentTrackers: 123 as unknown as string, torrentExcludeTrackers: 123 as unknown as string, + torrentTrackerConnectTimeout: 0, + torrentTrackerTimeout: 604801, + torrentTrackerInterval: -1, torrentStopTimeout: 604801, torrentPrioritizePiece: 'head=1G', torrentRemoveUnselectedFile: 'yes' as unknown as boolean, @@ -867,6 +870,9 @@ describe('useDownloadStore', () => { expect(normalized.torrentCheckIntegrity).toBeUndefined(); expect(normalized.torrentTrackers).toBeUndefined(); expect(normalized.torrentExcludeTrackers).toBeUndefined(); + expect(normalized.torrentTrackerConnectTimeout).toBeUndefined(); + expect(normalized.torrentTrackerTimeout).toBeUndefined(); + expect(normalized.torrentTrackerInterval).toBeUndefined(); expect(normalized.torrentStopTimeout).toBeUndefined(); expect(normalized.torrentPrioritizePiece).toBeUndefined(); expect(normalized.torrentRemoveUnselectedFile).toBeUndefined(); @@ -1524,6 +1530,9 @@ describe('useDownloadStore', () => { torrentCheckIntegrity: true, torrentTrackers: 'https://tracker.example/announce', torrentExcludeTrackers: '*', + torrentTrackerConnectTimeout: 11, + torrentTrackerTimeout: 22, + torrentTrackerInterval: 33, torrentStopTimeout: 300, torrentPrioritizePiece: 'head=1M,tail=1M', torrentEncryptionPolicy: 'force-encryption', @@ -1542,6 +1551,9 @@ describe('useDownloadStore', () => { torrent_check_integrity: true, torrent_trackers: 'https://tracker.example/announce', torrent_exclude_trackers: '*', + torrent_tracker_connect_timeout: 11, + torrent_tracker_timeout: 22, + torrent_tracker_interval: 33, torrent_stop_timeout: 300, torrent_prioritize_piece: 'head=1M,tail=1M', torrent_encryption_policy: 'force-encryption', diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 5f5f578..cdbdea8 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -9,7 +9,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope'; import type { Queue } from '../bindings/Queue'; import { useSettingsStore } from './useSettingsStore'; import { useDownloadProgressStore } from './downloadProgressStore'; -import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentPrioritizePiece, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; +import { canonicalizeDownloadFileName, categoryForFileName, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads'; import { resolveCategoryDestination } from '../utils/downloadLocations'; @@ -353,6 +353,9 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null): torrent_check_integrity: item.torrentCheckIntegrity, torrent_trackers: item.torrentTrackers || undefined, torrent_exclude_trackers: item.torrentExcludeTrackers || undefined, + torrent_tracker_connect_timeout: item.torrentTrackerConnectTimeout, + torrent_tracker_timeout: item.torrentTrackerTimeout, + torrent_tracker_interval: item.torrentTrackerInterval, torrent_stop_timeout: item.torrentStopTimeout, torrent_prioritize_piece: item.torrentPrioritizePiece || undefined, torrent_remove_unselected_file: item.torrentRemoveUnselectedFile, @@ -645,6 +648,12 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down const normalizedExcludeTrackers = typeof rawExcludeTrackers === 'string' && rawExcludeTrackers.trim() && isValidTorrentExcludeTrackerList(rawExcludeTrackers) ? rawExcludeTrackers.trim() : undefined; + const rawTrackerConnectTimeout = download.torrentTrackerConnectTimeout as unknown; + const normalizedTrackerConnectTimeout = normalizeTorrentTrackerTimeout(rawTrackerConnectTimeout); + const rawTrackerTimeout = download.torrentTrackerTimeout as unknown; + const normalizedTrackerTimeout = normalizeTorrentTrackerTimeout(rawTrackerTimeout); + const rawTrackerInterval = download.torrentTrackerInterval as unknown; + const normalizedTrackerInterval = normalizeTorrentTrackerInterval(rawTrackerInterval); const rawStopTimeout = download.torrentStopTimeout as unknown; const normalizedStopTimeout = typeof rawStopTimeout === 'number' && Number.isInteger(rawStopTimeout) && @@ -667,6 +676,9 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down rawCheckIntegrity !== normalizedCheckIntegrity || rawTrackers !== normalizedTrackers || rawExcludeTrackers !== normalizedExcludeTrackers || + rawTrackerConnectTimeout !== normalizedTrackerConnectTimeout || + rawTrackerTimeout !== normalizedTrackerTimeout || + rawTrackerInterval !== normalizedTrackerInterval || rawStopTimeout !== normalizedStopTimeout || rawPrioritizePiece !== normalizedPrioritizePiece || rawRemoveUnselectedFile !== normalizedRemoveUnselectedFile || @@ -678,6 +690,9 @@ export const normalizePersistedDownloadProgress = (download: DownloadItem): Down torrentCheckIntegrity: normalizedCheckIntegrity, torrentTrackers: normalizedTrackers, torrentExcludeTrackers: normalizedExcludeTrackers, + torrentTrackerConnectTimeout: normalizedTrackerConnectTimeout, + torrentTrackerTimeout: normalizedTrackerTimeout, + torrentTrackerInterval: normalizedTrackerInterval, torrentStopTimeout: normalizedStopTimeout, torrentPrioritizePiece: normalizedPrioritizePiece, torrentRemoveUnselectedFile: normalizedRemoveUnselectedFile, @@ -2215,6 +2230,9 @@ export const useDownloadStore = create((set, get) => { torrent_check_integrity: item.torrentCheckIntegrity, torrent_trackers: item.torrentTrackers || undefined, torrent_exclude_trackers: item.torrentExcludeTrackers || undefined, + torrent_tracker_connect_timeout: item.torrentTrackerConnectTimeout, + torrent_tracker_timeout: item.torrentTrackerTimeout, + torrent_tracker_interval: item.torrentTrackerInterval, torrent_stop_timeout: item.torrentStopTimeout, torrent_prioritize_piece: item.torrentPrioritizePiece || undefined, torrent_remove_unselected_file: item.torrentRemoveUnselectedFile, diff --git a/src/utils/downloads.test.ts b/src/utils/downloads.test.ts index b5abd2d..b012276 100644 --- a/src/utils/downloads.test.ts +++ b/src/utils/downloads.test.ts @@ -10,6 +10,8 @@ import { isValidTorrentTrackerList, normalizeTorrentEncryptionPolicy, normalizeTorrentPrioritizePiece, + normalizeTorrentTrackerInterval, + normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from './downloads'; @@ -107,6 +109,23 @@ describe('Torrent encryption policy validation', () => { }); }); +describe('Torrent tracker timing validation', () => { + it('accepts bounded timeout values and an automatic interval', () => { + expect(normalizeTorrentTrackerTimeout('1')).toBe(1); + expect(normalizeTorrentTrackerTimeout(604800)).toBe(604800); + expect(normalizeTorrentTrackerInterval('0')).toBe(0); + expect(normalizeTorrentTrackerInterval(604800)).toBe(604800); + }); + + it('rejects zero timeouts and out-of-range timing values', () => { + expect(normalizeTorrentTrackerTimeout('0')).toBeUndefined(); + expect(normalizeTorrentTrackerTimeout(604801)).toBeUndefined(); + expect(normalizeTorrentTrackerInterval(-1)).toBeUndefined(); + expect(normalizeTorrentTrackerInterval(604801)).toBeUndefined(); + expect(normalizeTorrentTrackerTimeout('1.5')).toBeUndefined(); + }); +}); + describe('download connection resolution', () => { it('uses a clamped fallback for legacy rows without a saved value', () => { expect(resolveDownloadConnections(undefined, 8)).toBe(8); diff --git a/src/utils/downloads.ts b/src/utils/downloads.ts index 304cb46..365cdb2 100644 --- a/src/utils/downloads.ts +++ b/src/utils/downloads.ts @@ -65,6 +65,34 @@ export const normalizeTorrentEncryptionPolicy = ( return undefined; }; +export const MAX_TORRENT_TRACKER_TIMEOUT = 604800; +export const MAX_TORRENT_TRACKER_INTERVAL = 604800; + +const parseIntegerOption = (value: unknown): number | undefined => { + if (typeof value === 'number') { + return Number.isInteger(value) ? value : undefined; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isInteger(parsed) ? parsed : undefined; + } + return undefined; +}; + +export const normalizeTorrentTrackerTimeout = (value: unknown): number | undefined => { + const parsed = parseIntegerOption(value); + return parsed !== undefined && parsed >= 1 && parsed <= MAX_TORRENT_TRACKER_TIMEOUT + ? parsed + : undefined; +}; + +export const normalizeTorrentTrackerInterval = (value: unknown): number | undefined => { + const parsed = parseIntegerOption(value); + return parsed !== undefined && parsed >= 0 && parsed <= MAX_TORRENT_TRACKER_INTERVAL + ? parsed + : undefined; +}; + // Keep every filename component within the common cross-platform filesystem // limit. Count UTF-8 bytes because POSIX filesystems enforce bytes, while this // bound is also conservative for Windows filename components.