Skip to main content

nzb_core/
config.rs

1use std::path::PathBuf;
2
3use anyhow::Context;
4use serde::{Deserialize, Serialize};
5
6/// Top-level application configuration.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(default)]
9pub struct AppConfig {
10    pub general: GeneralConfig,
11    pub servers: Vec<ServerConfig>,
12    pub categories: Vec<CategoryConfig>,
13    #[serde(default)]
14    pub otel: OtelConfig,
15    #[serde(default)]
16    pub rss_feeds: Vec<RssFeedConfig>,
17}
18
19impl Default for AppConfig {
20    fn default() -> Self {
21        Self {
22            general: GeneralConfig::default(),
23            servers: Vec::new(),
24            categories: vec![CategoryConfig::default()],
25            otel: OtelConfig::default(),
26            rss_feeds: Vec::new(),
27        }
28    }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32#[serde(default)]
33pub struct GeneralConfig {
34    /// HTTP API listen address
35    pub listen_addr: String,
36    /// HTTP API port
37    pub port: u16,
38    /// API key for authentication
39    pub api_key: Option<String>,
40    /// Directory for incomplete downloads
41    pub incomplete_dir: PathBuf,
42    /// Directory for completed downloads
43    pub complete_dir: PathBuf,
44    /// Directory for application data (DB, logs)
45    pub data_dir: PathBuf,
46    /// Download speed limit in bytes/sec (0 = unlimited)
47    pub speed_limit_bps: u64,
48    /// Article cache size in bytes
49    pub cache_size: u64,
50    /// Log level
51    pub log_level: String,
52    /// Log file path (None = stdout only)
53    pub log_file: Option<PathBuf>,
54    /// History retention: how many NZBs to keep in history (None = keep all)
55    pub history_retention: Option<usize>,
56    /// Max number of NZBs downloading simultaneously (default 1)
57    pub max_active_downloads: usize,
58    /// Minimum free disk space in bytes before pausing downloads (default 1 GB)
59    #[serde(default = "default_min_free_space")]
60    pub min_free_space_bytes: u64,
61    /// Directory to watch for new .nzb files to auto-enqueue
62    pub watch_dir: Option<PathBuf>,
63    /// RSS feed history limit: how many feed items to keep (None = keep all, default 500)
64    #[serde(default = "default_rss_history_limit")]
65    pub rss_history_limit: Option<usize>,
66    /// Begin extracting RAR volumes during download instead of waiting for the
67    /// job to complete. Requires `unrar` on PATH. Falls back to normal
68    /// post-processing if articles fail or unrar is unavailable.
69    #[serde(default = "default_true")]
70    pub direct_unpack: bool,
71    /// Abort downloads that cannot possibly complete (too many missing articles).
72    /// When enabled, the engine checks article failure rates and cancels jobs
73    /// that have no chance of success. Default: true.
74    #[serde(default = "default_true")]
75    pub abort_hopeless: bool,
76    /// Quick initial check: after the first N articles have been attempted,
77    /// abort if the failure rate exceeds 80%. Catches completely dead NZBs
78    /// within seconds instead of grinding through thousands of articles.
79    /// Requires `abort_hopeless` to also be enabled. Default: true.
80    #[serde(default = "default_true")]
81    pub early_failure_check: bool,
82    /// Minimum completion percentage required to keep downloading (excluding
83    /// par2 repair files). If the ratio of available content bytes to total
84    /// content bytes drops below this value, the job is aborted.
85    /// Range: 100.0–200.0. Default: 100.2 (par2 overhead means slight
86    /// over-completion is normal).
87    #[serde(default = "default_required_completion_pct")]
88    pub required_completion_pct: f64,
89    /// Maximum time in seconds to wait for a single NNTP article response
90    /// before treating the connection as stalled and reconnecting.
91    /// 0 = no timeout. Default: 30.
92    #[serde(default = "default_article_timeout_secs")]
93    pub article_timeout_secs: u64,
94}
95
96fn default_rss_history_limit() -> Option<usize> {
97    Some(500)
98}
99
100fn default_min_free_space() -> u64 {
101    1_073_741_824 // 1 GB
102}
103
104fn default_required_completion_pct() -> f64 {
105    100.2
106}
107
108fn default_article_timeout_secs() -> u64 {
109    30
110}
111
112impl Default for GeneralConfig {
113    fn default() -> Self {
114        Self {
115            listen_addr: "0.0.0.0".into(),
116            port: 9090,
117            api_key: None,
118            incomplete_dir: PathBuf::from("/downloads/incomplete"),
119            complete_dir: PathBuf::from("/downloads/complete"),
120            data_dir: PathBuf::from("/data"),
121            speed_limit_bps: 0,
122            cache_size: 500 * 1024 * 1024, // 500 MB
123            log_level: "info".into(),
124            log_file: None,
125            history_retention: None, // keep all
126            max_active_downloads: 1,
127            min_free_space_bytes: default_min_free_space(),
128            watch_dir: None,
129            rss_history_limit: default_rss_history_limit(),
130            direct_unpack: true,
131            abort_hopeless: true,
132            early_failure_check: true,
133            required_completion_pct: default_required_completion_pct(),
134            article_timeout_secs: default_article_timeout_secs(),
135        }
136    }
137}
138
139/// OpenTelemetry configuration. All values can be overridden via env vars.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default)]
142pub struct OtelConfig {
143    /// Enable OpenTelemetry export
144    pub enabled: bool,
145    /// OTLP endpoint for logs and metrics
146    pub endpoint: String,
147    /// Service name reported to the collector
148    pub service_name: String,
149}
150
151impl Default for OtelConfig {
152    fn default() -> Self {
153        Self {
154            enabled: false,
155            endpoint: "http://localhost:4317".into(),
156            service_name: "rustnzb".into(),
157        }
158    }
159}
160
161/// NNTP server configuration — re-exported from the `nzb-nntp` crate.
162pub use nzb_nntp::ServerConfig;
163
164/// Category configuration for organizing downloads.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct CategoryConfig {
167    /// Category name
168    pub name: String,
169    /// Output directory override (relative to complete_dir)
170    pub output_dir: Option<PathBuf>,
171    /// Post-processing level: 0=none, 1=repair, 2=unpack, 3=repair+unpack
172    pub post_processing: u8,
173}
174
175impl Default for CategoryConfig {
176    fn default() -> Self {
177        Self {
178            name: "Default".into(),
179            output_dir: None,
180            post_processing: 3,
181        }
182    }
183}
184
185/// RSS feed configuration for automatic NZB downloading.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct RssFeedConfig {
188    /// Display name for the feed
189    pub name: String,
190    /// Feed URL (RSS 2.0 or Atom)
191    pub url: String,
192    /// How often to poll, in seconds (default 900 = 15 minutes)
193    #[serde(default = "default_poll_interval")]
194    pub poll_interval_secs: u64,
195    /// Category to assign to downloaded NZBs
196    #[serde(default)]
197    pub category: Option<String>,
198    /// Regex pattern to filter feed entries by title
199    #[serde(default)]
200    pub filter_regex: Option<String>,
201    /// Whether this feed is active
202    #[serde(default = "default_true")]
203    pub enabled: bool,
204    /// Auto-download all items from this feed (no rules needed).
205    /// Ignored when filter_regex is set (use download rules instead).
206    #[serde(default)]
207    pub auto_download: bool,
208}
209
210fn default_poll_interval() -> u64 {
211    900
212}
213
214fn default_true() -> bool {
215    true
216}
217
218impl AppConfig {
219    /// Load config from a TOML file, creating default if it doesn't exist.
220    pub fn load(path: &std::path::Path) -> anyhow::Result<Self> {
221        if path.exists() {
222            let contents = std::fs::read_to_string(path)
223                .with_context(|| format!("Failed to read config file: {}", path.display()))?;
224            let config: AppConfig = toml::from_str(&contents)?;
225            Ok(config)
226        } else {
227            let config = AppConfig::default();
228            config.save(path).with_context(|| {
229                format!(
230                    "Failed to create default config at {}. \
231                     Check that the directory is writable by the current user. \
232                     If using Docker with 'user:', ensure volume directories are owned by that user.",
233                    path.display()
234                )
235            })?;
236            Ok(config)
237        }
238    }
239
240    /// Save config to a TOML file.
241    pub fn save(&self, path: &std::path::Path) -> anyhow::Result<()> {
242        if let Some(parent) = path.parent() {
243            std::fs::create_dir_all(parent).with_context(|| {
244                format!("Failed to create config directory: {}", parent.display())
245            })?;
246        }
247        let contents = toml::to_string_pretty(self)?;
248        std::fs::write(path, &contents)
249            .with_context(|| format!("Failed to write config file: {}", path.display()))?;
250        Ok(())
251    }
252
253    /// Find a category by name.
254    pub fn category(&self, name: &str) -> Option<&CategoryConfig> {
255        self.categories.iter().find(|c| c.name == name)
256    }
257
258    /// Find a server by ID.
259    pub fn server(&self, id: &str) -> Option<&ServerConfig> {
260        self.servers.iter().find(|s| s.id == id)
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn test_server_config_defaults() {
270        let cfg = ServerConfig::default();
271        assert_eq!(cfg.port, 563);
272        assert!(cfg.ssl);
273        assert!(cfg.ssl_verify);
274        assert!(cfg.username.is_none());
275        assert!(cfg.password.is_none());
276        assert_eq!(cfg.connections, 8);
277        assert_eq!(cfg.priority, 0);
278        assert!(cfg.enabled);
279        assert_eq!(cfg.retention, 0);
280        assert_eq!(cfg.pipelining, 1);
281        assert!(!cfg.optional);
282    }
283
284    #[test]
285    fn test_general_config_defaults() {
286        let cfg = GeneralConfig::default();
287        assert_eq!(cfg.listen_addr, "0.0.0.0");
288        assert_eq!(cfg.port, 9090);
289        assert!(cfg.api_key.is_none());
290        assert_eq!(cfg.speed_limit_bps, 0);
291        assert_eq!(cfg.cache_size, 500 * 1024 * 1024);
292        assert_eq!(cfg.log_level, "info");
293        assert!(cfg.log_file.is_none());
294        assert!(cfg.history_retention.is_none());
295        assert_eq!(cfg.max_active_downloads, 1);
296        assert_eq!(cfg.min_free_space_bytes, 1_073_741_824);
297        assert!(cfg.watch_dir.is_none());
298        assert_eq!(cfg.rss_history_limit, Some(500));
299    }
300
301    #[test]
302    fn test_app_config_defaults() {
303        let cfg = AppConfig::default();
304        assert!(cfg.servers.is_empty());
305        assert_eq!(cfg.categories.len(), 1);
306        assert_eq!(cfg.categories[0].name, "Default");
307        assert_eq!(cfg.categories[0].post_processing, 3);
308        assert!(!cfg.otel.enabled);
309        assert!(cfg.rss_feeds.is_empty());
310    }
311
312    #[test]
313    fn test_category_config_defaults() {
314        let cat = CategoryConfig::default();
315        assert_eq!(cat.name, "Default");
316        assert!(cat.output_dir.is_none());
317        assert_eq!(cat.post_processing, 3);
318    }
319
320    #[test]
321    fn test_server_config_toml_roundtrip() {
322        let mut original = ServerConfig::new("srv-1", "news.example.com");
323        original.name = "Usenet Provider".into();
324        original.username = Some("user".into());
325        original.password = Some("pass".into());
326        original.connections = 20;
327        original.retention = 3000;
328        original.pipelining = 5;
329        original.ramp_up_delay_ms = 0;
330        original.recv_buffer_size = 0;
331
332        let toml_str = toml::to_string_pretty(&original).unwrap();
333        let restored: ServerConfig = toml::from_str(&toml_str).unwrap();
334
335        assert_eq!(restored.id, original.id);
336        assert_eq!(restored.name, original.name);
337        assert_eq!(restored.host, original.host);
338        assert_eq!(restored.port, original.port);
339        assert_eq!(restored.ssl, original.ssl);
340        assert_eq!(restored.username, original.username);
341        assert_eq!(restored.password, original.password);
342        assert_eq!(restored.connections, original.connections);
343        assert_eq!(restored.priority, original.priority);
344        assert_eq!(restored.retention, original.retention);
345        assert_eq!(restored.pipelining, original.pipelining);
346        assert_eq!(restored.optional, original.optional);
347    }
348
349    #[test]
350    fn test_app_config_toml_roundtrip() {
351        let mut original = AppConfig::default();
352        let mut srv = ServerConfig::new("test-srv", "news.test.com");
353        srv.name = "Test".into();
354        srv.port = 119;
355        srv.ssl = false;
356        srv.ssl_verify = false;
357        srv.connections = 4;
358        srv.priority = 1;
359        srv.optional = true;
360        srv.ramp_up_delay_ms = 0;
361        srv.recv_buffer_size = 0;
362        original.servers.push(srv);
363        original.general.speed_limit_bps = 1_000_000;
364        original.general.api_key = Some("secret-key".into());
365
366        let toml_str = toml::to_string_pretty(&original).unwrap();
367        let restored: AppConfig = toml::from_str(&toml_str).unwrap();
368
369        assert_eq!(restored.servers.len(), 1);
370        assert_eq!(restored.servers[0].host, "news.test.com");
371        assert!(!restored.servers[0].ssl);
372        assert!(restored.servers[0].optional);
373        assert_eq!(restored.general.speed_limit_bps, 1_000_000);
374        assert_eq!(restored.general.api_key.as_deref(), Some("secret-key"));
375        assert_eq!(restored.categories.len(), 1);
376    }
377
378    #[test]
379    fn test_config_save_and_load() {
380        let dir = tempfile::tempdir().unwrap();
381        let path = dir.path().join("config.toml");
382
383        let mut original = AppConfig::default();
384        let mut srv = ServerConfig::new("file-srv", "news.file.com");
385        srv.name = "File Test".into();
386        original.servers.push(srv);
387        original.general.port = 8888;
388
389        original.save(&path).unwrap();
390        assert!(path.exists());
391
392        let loaded = AppConfig::load(&path).unwrap();
393        assert_eq!(loaded.servers.len(), 1);
394        assert_eq!(loaded.servers[0].id, "file-srv");
395        assert_eq!(loaded.general.port, 8888);
396    }
397
398    #[test]
399    fn test_config_load_creates_default_when_missing() {
400        let dir = tempfile::tempdir().unwrap();
401        let path = dir.path().join("nonexistent.toml");
402
403        let config = AppConfig::load(&path).unwrap();
404        assert!(config.servers.is_empty());
405        // File should now exist with default config
406        assert!(path.exists());
407    }
408
409    #[test]
410    fn test_config_find_category() {
411        let mut cfg = AppConfig::default();
412        cfg.categories.push(CategoryConfig {
413            name: "movies".into(),
414            output_dir: Some("/movies".into()),
415            post_processing: 3,
416        });
417
418        assert!(cfg.category("Default").is_some());
419        assert!(cfg.category("movies").is_some());
420        assert_eq!(cfg.category("movies").unwrap().post_processing, 3);
421        assert!(cfg.category("nonexistent").is_none());
422    }
423
424    #[test]
425    fn test_config_find_server() {
426        let mut cfg = AppConfig::default();
427        let mut srv = ServerConfig::new("primary", "news.primary.com");
428        srv.name = "Primary".into();
429        cfg.servers.push(srv);
430
431        assert!(cfg.server("primary").is_some());
432        assert_eq!(cfg.server("primary").unwrap().host, "news.primary.com");
433        assert!(cfg.server("nonexistent").is_none());
434    }
435
436    #[test]
437    fn test_rss_feed_config_defaults() {
438        let toml_str = r#"
439            name = "Test Feed"
440            url = "https://example.com/rss"
441        "#;
442        let feed: RssFeedConfig = toml::from_str(toml_str).unwrap();
443        assert_eq!(feed.name, "Test Feed");
444        assert_eq!(feed.poll_interval_secs, 900);
445        assert!(feed.enabled);
446        assert!(!feed.auto_download);
447        assert!(feed.category.is_none());
448        assert!(feed.filter_regex.is_none());
449    }
450}