Skip to main content

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