gosh_dl/
config.rs

1//! Engine configuration
2//!
3//! This module contains all configuration options for the download engine.
4
5use crate::error::{EngineError, Result};
6use crate::scheduler::ScheduleRule;
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9
10/// Main configuration for the download engine
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct EngineConfig {
13    /// Directory to save downloads
14    pub download_dir: PathBuf,
15
16    /// Maximum concurrent downloads
17    pub max_concurrent_downloads: usize,
18
19    /// Maximum connections per download (for segmented HTTP)
20    pub max_connections_per_download: usize,
21
22    /// Minimum segment size in bytes (won't split smaller than this)
23    pub min_segment_size: u64,
24
25    /// Global download speed limit (bytes/sec, None = unlimited)
26    pub global_download_limit: Option<u64>,
27
28    /// Global upload speed limit (bytes/sec, None = unlimited)
29    pub global_upload_limit: Option<u64>,
30
31    /// Bandwidth schedule rules for time-based limits
32    /// Rules are evaluated in order, first match wins
33    #[serde(default)]
34    pub schedule_rules: Vec<ScheduleRule>,
35
36    /// Default user agent
37    pub user_agent: String,
38
39    /// Enable DHT for torrents
40    pub enable_dht: bool,
41
42    /// Enable PEX (Peer Exchange) for torrents
43    pub enable_pex: bool,
44
45    /// Enable LPD (Local Peer Discovery) for torrents
46    pub enable_lpd: bool,
47
48    /// Maximum peers per torrent
49    pub max_peers: usize,
50
51    /// Stop seeding when this ratio is reached
52    pub seed_ratio: f64,
53
54    /// Database path for session persistence
55    pub database_path: Option<PathBuf>,
56
57    /// HTTP configuration
58    pub http: HttpConfig,
59
60    /// BitTorrent configuration
61    pub torrent: TorrentConfig,
62}
63
64/// HTTP-specific configuration
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct HttpConfig {
67    /// Connection timeout in seconds
68    pub connect_timeout: u64,
69
70    /// Read timeout in seconds
71    pub read_timeout: u64,
72
73    /// Maximum redirects to follow
74    pub max_redirects: usize,
75
76    /// Retry attempts for failed segments
77    pub max_retries: usize,
78
79    /// Initial retry delay in milliseconds
80    pub retry_delay_ms: u64,
81
82    /// Maximum retry delay in milliseconds
83    pub max_retry_delay_ms: u64,
84
85    /// Whether to accept invalid TLS certificates (dangerous!)
86    pub accept_invalid_certs: bool,
87
88    /// Proxy URL (e.g., "http://proxy:8080" or "socks5://proxy:1080")
89    /// Supports HTTP, HTTPS, and SOCKS5 proxies
90    pub proxy_url: Option<String>,
91}
92
93/// File allocation mode for torrent downloads
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
95#[serde(rename_all = "lowercase")]
96pub enum AllocationMode {
97    /// No preallocation (default) - files grow as data is written
98    #[default]
99    None,
100    /// Sparse allocation - set file size but don't write zeros (fast, most filesystems)
101    Sparse,
102    /// Full allocation - preallocate entire file with zeros (slow but prevents fragmentation)
103    Full,
104}
105
106impl std::fmt::Display for AllocationMode {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        match self {
109            Self::None => write!(f, "none"),
110            Self::Sparse => write!(f, "sparse"),
111            Self::Full => write!(f, "full"),
112        }
113    }
114}
115
116impl std::str::FromStr for AllocationMode {
117    type Err = String;
118
119    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
120        match s.to_lowercase().as_str() {
121            "none" => Ok(Self::None),
122            "sparse" => Ok(Self::Sparse),
123            "full" | "preallocate" => Ok(Self::Full),
124            _ => Err(format!("Invalid allocation mode: {}", s)),
125        }
126    }
127}
128
129/// BitTorrent-specific configuration
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct TorrentConfig {
132    /// Port range for incoming connections
133    pub listen_port_range: (u16, u16),
134
135    /// DHT bootstrap nodes
136    pub dht_bootstrap_nodes: Vec<String>,
137
138    /// File allocation mode (none, sparse, or full)
139    #[serde(default)]
140    pub allocation_mode: AllocationMode,
141
142    /// Tracker update interval in seconds
143    pub tracker_update_interval: u64,
144
145    /// Peer request timeout in seconds
146    pub peer_timeout: u64,
147
148    /// Maximum outstanding piece requests per peer
149    pub max_pending_requests: usize,
150
151    /// Enable endgame mode
152    pub enable_endgame: bool,
153
154    /// Peer loop tick interval in milliseconds.
155    /// Controls how frequently the peer loop checks for state changes and cleanup.
156    /// Default: 100ms. Lower values increase responsiveness but use more CPU.
157    #[serde(default = "default_tick_interval_ms")]
158    pub tick_interval_ms: u64,
159
160    /// Peer connection attempt interval in seconds.
161    /// Controls how frequently we attempt to connect to new peers.
162    /// Default: 5 seconds.
163    #[serde(default = "default_connect_interval_secs")]
164    pub connect_interval_secs: u64,
165
166    /// Choking algorithm update interval in seconds.
167    /// Controls how frequently we recalculate which peers to unchoke.
168    /// Per BEP 3, this should be around 10 seconds for regular unchoke
169    /// and 30 seconds for optimistic unchoke.
170    /// Default: 10 seconds.
171    #[serde(default = "default_choking_interval_secs")]
172    pub choking_interval_secs: u64,
173
174    /// WebSeed configuration
175    #[serde(default)]
176    pub webseed: WebSeedConfig,
177
178    /// Encryption configuration (MSE/PE)
179    #[serde(default)]
180    pub encryption: EncryptionConfig,
181
182    /// uTP transport configuration
183    #[serde(default)]
184    pub utp: UtpConfigSettings,
185}
186
187/// WebSeed-specific configuration (BEP 19/BEP 17)
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct WebSeedConfig {
190    /// Enable web seed downloads
191    #[serde(default = "default_true")]
192    pub enabled: bool,
193
194    /// Maximum concurrent web seed connections per torrent
195    #[serde(default = "default_webseed_connections")]
196    pub max_connections: usize,
197
198    /// Request timeout in seconds
199    #[serde(default = "default_webseed_timeout")]
200    pub timeout_seconds: u64,
201
202    /// Maximum consecutive failures before disabling a web seed
203    #[serde(default = "default_webseed_max_failures")]
204    pub max_failures: u32,
205}
206
207fn default_true() -> bool {
208    true
209}
210
211fn default_webseed_connections() -> usize {
212    4
213}
214
215fn default_webseed_timeout() -> u64 {
216    30
217}
218
219fn default_webseed_max_failures() -> u32 {
220    5
221}
222
223fn default_tick_interval_ms() -> u64 {
224    100
225}
226
227fn default_connect_interval_secs() -> u64 {
228    5
229}
230
231fn default_choking_interval_secs() -> u64 {
232    10
233}
234
235impl Default for WebSeedConfig {
236    fn default() -> Self {
237        Self {
238            enabled: true,
239            max_connections: 4,
240            timeout_seconds: 30,
241            max_failures: 5,
242        }
243    }
244}
245
246/// Encryption policy for peer connections (MSE/PE)
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
248#[serde(rename_all = "lowercase")]
249pub enum EncryptionPolicy {
250    /// Disable encryption entirely (plaintext only)
251    Disabled,
252    /// Allow encryption but don't require it (accept both)
253    Allowed,
254    /// Prefer encryption, fall back to plaintext if peer doesn't support
255    #[default]
256    Preferred,
257    /// Require encryption (reject non-MSE peers)
258    Required,
259}
260
261impl std::fmt::Display for EncryptionPolicy {
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        match self {
264            Self::Disabled => write!(f, "disabled"),
265            Self::Allowed => write!(f, "allowed"),
266            Self::Preferred => write!(f, "preferred"),
267            Self::Required => write!(f, "required"),
268        }
269    }
270}
271
272/// Encryption configuration for peer connections (MSE/PE)
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct EncryptionConfig {
275    /// Encryption policy
276    #[serde(default)]
277    pub policy: EncryptionPolicy,
278
279    /// Allow plaintext as fallback (when policy is Preferred)
280    #[serde(default = "default_true")]
281    pub allow_plaintext: bool,
282
283    /// Allow RC4 encryption
284    #[serde(default = "default_true")]
285    pub allow_rc4: bool,
286
287    /// Minimum random padding bytes for obfuscation
288    #[serde(default)]
289    pub min_padding: usize,
290
291    /// Maximum random padding bytes for obfuscation
292    #[serde(default = "default_max_padding")]
293    pub max_padding: usize,
294}
295
296fn default_max_padding() -> usize {
297    512
298}
299
300impl Default for EncryptionConfig {
301    fn default() -> Self {
302        Self {
303            policy: EncryptionPolicy::Preferred,
304            allow_plaintext: true,
305            allow_rc4: true,
306            min_padding: 0,
307            max_padding: 512,
308        }
309    }
310}
311
312/// Transport policy for peer connections
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
314#[serde(rename_all = "lowercase")]
315pub enum TransportPolicy {
316    /// Use TCP only
317    TcpOnly,
318    /// Use uTP only
319    UtpOnly,
320    /// Prefer uTP, fall back to TCP (default)
321    #[default]
322    PreferUtp,
323    /// Prefer TCP, fall back to uTP
324    PreferTcp,
325}
326
327impl std::fmt::Display for TransportPolicy {
328    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329        match self {
330            Self::TcpOnly => write!(f, "tcp-only"),
331            Self::UtpOnly => write!(f, "utp-only"),
332            Self::PreferUtp => write!(f, "prefer-utp"),
333            Self::PreferTcp => write!(f, "prefer-tcp"),
334        }
335    }
336}
337
338/// uTP (Micro Transport Protocol) configuration
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct UtpConfigSettings {
341    /// Enable uTP transport
342    #[serde(default = "default_true")]
343    pub enabled: bool,
344
345    /// Transport policy (prefer-utp, prefer-tcp, utp-only, tcp-only)
346    #[serde(default)]
347    pub policy: TransportPolicy,
348
349    /// Enable TCP fallback when uTP fails
350    #[serde(default = "default_true")]
351    pub tcp_fallback: bool,
352
353    /// Target delay in microseconds for LEDBAT (default: 100,000 = 100ms)
354    #[serde(default = "default_target_delay")]
355    pub target_delay_us: u32,
356
357    /// Maximum congestion window size in bytes (default: 1MB)
358    #[serde(default = "default_max_window")]
359    pub max_window_size: u32,
360
361    /// Initial receive window size in bytes (default: 1MB)
362    #[serde(default = "default_recv_window")]
363    pub recv_window: u32,
364
365    /// Enable selective ACK extension
366    #[serde(default = "default_true")]
367    pub enable_sack: bool,
368}
369
370fn default_target_delay() -> u32 {
371    100_000 // 100ms
372}
373
374fn default_max_window() -> u32 {
375    1024 * 1024 // 1MB
376}
377
378fn default_recv_window() -> u32 {
379    1024 * 1024 // 1MB
380}
381
382impl Default for UtpConfigSettings {
383    fn default() -> Self {
384        Self {
385            enabled: true,
386            policy: TransportPolicy::PreferUtp,
387            tcp_fallback: true,
388            target_delay_us: 100_000,
389            max_window_size: 1024 * 1024,
390            recv_window: 1024 * 1024,
391            enable_sack: true,
392        }
393    }
394}
395
396impl Default for EngineConfig {
397    fn default() -> Self {
398        Self {
399            download_dir: dirs::download_dir().unwrap_or_else(|| PathBuf::from(".")),
400            max_concurrent_downloads: 5,
401            max_connections_per_download: 16,
402            min_segment_size: 1024 * 1024, // 1 MiB
403            global_download_limit: None,
404            global_upload_limit: None,
405            schedule_rules: Vec::new(),
406            user_agent: format!("gosh-dl/{}", env!("CARGO_PKG_VERSION")),
407            enable_dht: true,
408            enable_pex: true,
409            enable_lpd: true,
410            max_peers: 55,
411            seed_ratio: 1.0,
412            database_path: None,
413            http: HttpConfig::default(),
414            torrent: TorrentConfig::default(),
415        }
416    }
417}
418
419impl Default for HttpConfig {
420    fn default() -> Self {
421        Self {
422            connect_timeout: 30,
423            read_timeout: 60,
424            max_redirects: 10,
425            max_retries: 3,
426            retry_delay_ms: 1000,
427            max_retry_delay_ms: 30000,
428            accept_invalid_certs: false,
429            proxy_url: None,
430        }
431    }
432}
433
434impl Default for TorrentConfig {
435    fn default() -> Self {
436        Self {
437            listen_port_range: (6881, 6889),
438            dht_bootstrap_nodes: vec![
439                "router.bittorrent.com:6881".to_string(),
440                "router.utorrent.com:6881".to_string(),
441                "dht.transmissionbt.com:6881".to_string(),
442            ],
443            allocation_mode: AllocationMode::None,
444            tracker_update_interval: 1800, // 30 minutes
445            peer_timeout: 120,
446            max_pending_requests: 16,
447            enable_endgame: true,
448            tick_interval_ms: 100,
449            connect_interval_secs: 5,
450            choking_interval_secs: 10,
451            webseed: WebSeedConfig::default(),
452            encryption: EncryptionConfig::default(),
453            utp: UtpConfigSettings::default(),
454        }
455    }
456}
457
458impl EngineConfig {
459    /// Create a new config with default values
460    pub fn new() -> Self {
461        Self::default()
462    }
463
464    /// Set the download directory
465    pub fn download_dir(mut self, path: impl Into<PathBuf>) -> Self {
466        self.download_dir = path.into();
467        self
468    }
469
470    /// Set maximum concurrent downloads
471    pub fn max_concurrent_downloads(mut self, max: usize) -> Self {
472        self.max_concurrent_downloads = max;
473        self
474    }
475
476    /// Set maximum connections per download
477    pub fn max_connections_per_download(mut self, max: usize) -> Self {
478        self.max_connections_per_download = max;
479        self
480    }
481
482    /// Set global download speed limit
483    pub fn download_limit(mut self, limit: Option<u64>) -> Self {
484        self.global_download_limit = limit;
485        self
486    }
487
488    /// Set global upload speed limit
489    pub fn upload_limit(mut self, limit: Option<u64>) -> Self {
490        self.global_upload_limit = limit;
491        self
492    }
493
494    /// Set the user agent
495    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
496        self.user_agent = ua.into();
497        self
498    }
499
500    /// Set bandwidth schedule rules
501    pub fn schedule_rules(mut self, rules: Vec<ScheduleRule>) -> Self {
502        self.schedule_rules = rules;
503        self
504    }
505
506    /// Add a bandwidth schedule rule
507    pub fn add_schedule_rule(mut self, rule: ScheduleRule) -> Self {
508        self.schedule_rules.push(rule);
509        self
510    }
511
512    /// Set the database path for persistence
513    pub fn database_path(mut self, path: impl Into<PathBuf>) -> Self {
514        self.database_path = Some(path.into());
515        self
516    }
517
518    /// Validate the configuration
519    pub fn validate(&self) -> Result<()> {
520        // Check download directory
521        if !self.download_dir.exists() {
522            return Err(EngineError::invalid_input(
523                "download_dir",
524                format!("Directory does not exist: {:?}", self.download_dir),
525            ));
526        }
527
528        if !self.download_dir.is_dir() {
529            return Err(EngineError::invalid_input(
530                "download_dir",
531                format!("Path is not a directory: {:?}", self.download_dir),
532            ));
533        }
534
535        // Check numeric limits
536        if self.max_concurrent_downloads == 0 {
537            return Err(EngineError::invalid_input(
538                "max_concurrent_downloads",
539                "Must be at least 1",
540            ));
541        }
542
543        if self.max_connections_per_download == 0 {
544            return Err(EngineError::invalid_input(
545                "max_connections_per_download",
546                "Must be at least 1",
547            ));
548        }
549
550        if self.seed_ratio < 0.0 {
551            return Err(EngineError::invalid_input(
552                "seed_ratio",
553                "Must be non-negative",
554            ));
555        }
556
557        // Check port range
558        if self.torrent.listen_port_range.0 > self.torrent.listen_port_range.1 {
559            return Err(EngineError::invalid_input(
560                "listen_port_range",
561                "Start port must be <= end port",
562            ));
563        }
564
565        Ok(())
566    }
567
568    /// Get the database path, using default if not set
569    pub fn get_database_path(&self) -> PathBuf {
570        self.database_path.clone().unwrap_or_else(|| {
571            dirs::data_dir()
572                .unwrap_or_else(|| PathBuf::from("."))
573                .join("gosh-dl")
574                .join("gosh-dl.db")
575        })
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use tempfile::tempdir;
583
584    #[test]
585    fn test_default_config() {
586        let config = EngineConfig::default();
587        assert_eq!(config.max_concurrent_downloads, 5);
588        assert_eq!(config.max_connections_per_download, 16);
589        assert!(config.enable_dht);
590    }
591
592    #[test]
593    fn test_config_builder() {
594        let config = EngineConfig::new()
595            .max_concurrent_downloads(10)
596            .max_connections_per_download(8)
597            .download_limit(Some(1024 * 1024));
598
599        assert_eq!(config.max_concurrent_downloads, 10);
600        assert_eq!(config.max_connections_per_download, 8);
601        assert_eq!(config.global_download_limit, Some(1024 * 1024));
602    }
603
604    #[test]
605    fn test_config_validation() {
606        let dir = tempdir().unwrap();
607        let config = EngineConfig::new().download_dir(dir.path());
608        assert!(config.validate().is_ok());
609    }
610
611    #[test]
612    fn test_invalid_download_dir() {
613        let config = EngineConfig::new().download_dir("/nonexistent/path/12345");
614        assert!(config.validate().is_err());
615    }
616}