audiobook_forge/models/
config.rs

1//! Configuration model
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6/// Main configuration structure
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct Config {
9    #[serde(default)]
10    pub directories: DirectoryConfig,
11    #[serde(default)]
12    pub performance: PerformanceConfig,
13    #[serde(default)]
14    pub processing: ProcessingConfig,
15    #[serde(default)]
16    pub quality: QualityConfig,
17    #[serde(default)]
18    pub metadata: MetadataConfig,
19    #[serde(default)]
20    pub organization: OrganizationConfig,
21    #[serde(default)]
22    pub logging: LoggingConfig,
23    #[serde(default)]
24    pub advanced: AdvancedConfig,
25}
26
27impl Default for Config {
28    fn default() -> Self {
29        Self {
30            directories: DirectoryConfig::default(),
31            performance: PerformanceConfig::default(),
32            processing: ProcessingConfig::default(),
33            quality: QualityConfig::default(),
34            metadata: MetadataConfig::default(),
35            organization: OrganizationConfig::default(),
36            logging: LoggingConfig::default(),
37            advanced: AdvancedConfig::default(),
38        }
39    }
40}
41
42/// Directory configuration
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct DirectoryConfig {
45    /// Source directory for audiobooks (overrides --root)
46    pub source: Option<PathBuf>,
47    /// Output directory ("same_as_source" or custom path)
48    #[serde(default = "default_output")]
49    pub output: String,
50}
51
52impl Default for DirectoryConfig {
53    fn default() -> Self {
54        Self {
55            source: None,
56            output: "same_as_source".to_string(),
57        }
58    }
59}
60
61fn default_output() -> String {
62    "same_as_source".to_string()
63}
64
65/// Performance configuration
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct PerformanceConfig {
68    /// Maximum number of files to encode in parallel
69    /// "auto" = use all CPU cores, or specify a number
70    #[serde(default = "default_max_concurrent_encodes")]
71    pub max_concurrent_encodes: String,
72    /// Enable parallel file encoding (faster but more CPU/memory)
73    #[serde(default = "default_true")]
74    pub enable_parallel_encoding: bool,
75    /// Encoding quality preset: "fast", "balanced", "high"
76    #[serde(default = "default_encoding_preset")]
77    pub encoding_preset: String,
78    /// Maximum concurrent file encodings per book (prevents resource exhaustion)
79    #[serde(default = "default_max_concurrent_files_per_book")]
80    pub max_concurrent_files_per_book: String,
81}
82
83impl Default for PerformanceConfig {
84    fn default() -> Self {
85        Self {
86            max_concurrent_encodes: "auto".to_string(),
87            enable_parallel_encoding: true,
88            encoding_preset: "balanced".to_string(),
89            max_concurrent_files_per_book: "8".to_string(),
90        }
91    }
92}
93
94fn default_max_concurrent_encodes() -> String {
95    "auto".to_string()
96}
97
98fn default_encoding_preset() -> String {
99    "balanced".to_string()
100}
101
102fn default_max_concurrent_files_per_book() -> String {
103    "8".to_string()
104}
105
106/// Processing configuration
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ProcessingConfig {
109    /// Number of parallel workers (1-8)
110    #[serde(default = "default_parallel_workers")]
111    pub parallel_workers: u8,
112    /// Skip folders with existing M4B files
113    #[serde(default = "default_true")]
114    pub skip_existing: bool,
115    /// Always reprocess, overwriting existing files
116    #[serde(default)]
117    pub force_reprocess: bool,
118    /// Normalize existing M4B files (fix metadata)
119    #[serde(default)]
120    pub normalize_existing: bool,
121    /// Keep temporary files for debugging
122    #[serde(default)]
123    pub keep_temp_files: bool,
124    /// Maximum number of retry attempts
125    #[serde(default = "default_max_retries")]
126    pub max_retries: u8,
127    /// Initial retry delay in seconds
128    #[serde(default = "default_retry_delay")]
129    pub retry_delay: u64,
130}
131
132impl Default for ProcessingConfig {
133    fn default() -> Self {
134        Self {
135            parallel_workers: 2,
136            skip_existing: true,
137            force_reprocess: false,
138            normalize_existing: false,
139            keep_temp_files: false,
140            max_retries: 2,
141            retry_delay: 1,
142        }
143    }
144}
145
146fn default_max_retries() -> u8 {
147    2
148}
149
150fn default_retry_delay() -> u64 {
151    1
152}
153
154fn default_parallel_workers() -> u8 {
155    2
156}
157
158fn default_true() -> bool {
159    true
160}
161
162/// Quality configuration
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct QualityConfig {
165    /// Prefer stereo over mono when quality is equal
166    #[serde(default = "default_true")]
167    pub prefer_stereo: bool,
168    /// Chapter source priority ("auto", "files", "cue", etc.)
169    #[serde(default = "default_chapter_source")]
170    pub chapter_source: String,
171    /// Default bitrate in kbps ("auto" or specific: 64, 128, 256)
172    #[serde(default = "default_bitrate")]
173    pub default_bitrate: String,
174    /// Default sample rate in Hz ("auto" or specific: 44100, 48000)
175    #[serde(default = "default_sample_rate")]
176    pub default_sample_rate: String,
177}
178
179impl Default for QualityConfig {
180    fn default() -> Self {
181        Self {
182            prefer_stereo: true,
183            chapter_source: "auto".to_string(),
184            default_bitrate: "auto".to_string(),
185            default_sample_rate: "auto".to_string(),
186        }
187    }
188}
189
190fn default_chapter_source() -> String {
191    "auto".to_string()
192}
193
194fn default_bitrate() -> String {
195    "auto".to_string()
196}
197
198fn default_sample_rate() -> String {
199    "auto".to_string()
200}
201
202/// Metadata configuration
203#[derive(Debug, Clone, Serialize, Deserialize)]
204pub struct MetadataConfig {
205    /// Default language for metadata (ISO 639-1)
206    #[serde(default = "default_language")]
207    pub default_language: String,
208    /// Cover art filenames to search for
209    #[serde(default = "default_cover_filenames")]
210    pub cover_filenames: Vec<String>,
211    /// Auto-extract embedded cover art from audio files as fallback
212    #[serde(default = "default_auto_extract_cover")]
213    pub auto_extract_cover: bool,
214    /// Audible metadata integration
215    #[serde(default)]
216    pub audible: AudibleConfig,
217    /// Matching mode for build command
218    #[serde(default)]
219    pub match_mode: MatchMode,
220}
221
222impl Default for MetadataConfig {
223    fn default() -> Self {
224        Self {
225            default_language: "es".to_string(),
226            cover_filenames: vec![
227                "cover.jpg".to_string(),
228                "folder.jpg".to_string(),
229                "cover.png".to_string(),
230                "folder.png".to_string(),
231            ],
232            auto_extract_cover: true,
233            audible: AudibleConfig::default(),
234            match_mode: MatchMode::default(),
235        }
236    }
237}
238
239fn default_language() -> String {
240    "es".to_string()
241}
242
243fn default_cover_filenames() -> Vec<String> {
244    vec![
245        "cover.jpg".to_string(),
246        "folder.jpg".to_string(),
247        "cover.png".to_string(),
248        "folder.png".to_string(),
249    ]
250}
251
252fn default_auto_extract_cover() -> bool {
253    true
254}
255
256/// Matching mode for interactive metadata matching during build
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
258#[serde(rename_all = "lowercase")]
259pub enum MatchMode {
260    /// Disabled - don't match during build
261    Disabled,
262    /// Auto - automatically select best match (non-interactive)
263    Auto,
264    /// Interactive - prompt user for each file
265    Interactive,
266}
267
268impl Default for MatchMode {
269    fn default() -> Self {
270        MatchMode::Disabled
271    }
272}
273
274/// Audible metadata integration configuration
275#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct AudibleConfig {
277    /// Enable Audible metadata fetching
278    #[serde(default)]
279    pub enabled: bool,
280    /// Default Audible region for queries
281    #[serde(default = "default_audible_region")]
282    pub region: String,
283    /// Auto-match books by folder name during build
284    #[serde(default)]
285    pub auto_match: bool,
286    /// Download and embed cover art from Audible
287    #[serde(default = "default_true")]
288    pub download_covers: bool,
289    /// Fetch chapter data from Audnex API during build
290    #[serde(default)]
291    pub fetch_chapters: bool,
292    /// Cache metadata locally (hours, 0 = no cache)
293    #[serde(default = "default_cache_duration")]
294    pub cache_duration_hours: u64,
295    /// Rate limit (requests per minute)
296    #[serde(default = "default_rate_limit")]
297    pub rate_limit_per_minute: u32,
298    /// Maximum retry attempts for API failures (0 = no retry)
299    #[serde(default = "default_api_max_retries")]
300    pub api_max_retries: u8,
301    /// Initial retry delay in seconds
302    #[serde(default = "default_api_retry_delay")]
303    pub api_retry_delay_secs: u64,
304    /// Maximum retry delay in seconds (for exponential backoff)
305    #[serde(default = "default_api_max_retry_delay")]
306    pub api_max_retry_delay_secs: u64,
307}
308
309impl Default for AudibleConfig {
310    fn default() -> Self {
311        Self {
312            enabled: false,
313            region: "us".to_string(),
314            auto_match: false,
315            download_covers: true,
316            fetch_chapters: false,
317            cache_duration_hours: 168, // 7 days
318            rate_limit_per_minute: 100,
319            api_max_retries: 3,
320            api_retry_delay_secs: 1,
321            api_max_retry_delay_secs: 30,
322        }
323    }
324}
325
326fn default_audible_region() -> String {
327    "us".to_string()
328}
329
330fn default_cache_duration() -> u64 {
331    168 // 7 days
332}
333
334fn default_rate_limit() -> u32 {
335    100
336}
337
338fn default_api_max_retries() -> u8 {
339    3
340}
341
342fn default_api_retry_delay() -> u64 {
343    1
344}
345
346fn default_api_max_retry_delay() -> u64 {
347    30
348}
349
350/// Organization configuration
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct OrganizationConfig {
353    /// Name for completed audiobooks folder
354    #[serde(default = "default_m4b_folder")]
355    pub m4b_folder: String,
356    /// Name for conversion queue folder
357    #[serde(default = "default_convert_folder")]
358    pub convert_folder: String,
359}
360
361impl Default for OrganizationConfig {
362    fn default() -> Self {
363        Self {
364            m4b_folder: "M4B".to_string(),
365            convert_folder: "To_Convert".to_string(),
366        }
367    }
368}
369
370fn default_m4b_folder() -> String {
371    "M4B".to_string()
372}
373
374fn default_convert_folder() -> String {
375    "To_Convert".to_string()
376}
377
378/// Logging configuration
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct LoggingConfig {
381    /// Enable automatic log file creation
382    #[serde(default)]
383    pub log_to_file: bool,
384    /// Custom log file path
385    pub log_file: Option<PathBuf>,
386    /// Log level ("INFO", "DEBUG", "WARNING", "ERROR")
387    #[serde(default = "default_log_level")]
388    pub log_level: String,
389}
390
391impl Default for LoggingConfig {
392    fn default() -> Self {
393        Self {
394            log_to_file: false,
395            log_file: None,
396            log_level: "INFO".to_string(),
397        }
398    }
399}
400
401fn default_log_level() -> String {
402    "INFO".to_string()
403}
404
405fn default_aac_encoder() -> String {
406    "auto".to_string()
407}
408
409/// Advanced configuration
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct AdvancedConfig {
412    /// Custom FFmpeg binary path
413    pub ffmpeg_path: Option<PathBuf>,
414    /// Custom AtomicParsley binary path
415    pub atomic_parsley_path: Option<PathBuf>,
416    /// Custom MP4Box binary path
417    pub mp4box_path: Option<PathBuf>,
418    /// Custom temporary files location
419    pub temp_directory: Option<PathBuf>,
420    /// DEPRECATED: Use aac_encoder instead
421    /// Use Apple Silicon hardware encoder (aac_at)
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub use_apple_silicon_encoder: Option<bool>,
424    /// AAC encoder preference: "auto", "aac_at", "libfdk_aac", "aac"
425    #[serde(default = "default_aac_encoder")]
426    pub aac_encoder: String,
427}
428
429impl Default for AdvancedConfig {
430    fn default() -> Self {
431        Self {
432            ffmpeg_path: None,
433            atomic_parsley_path: None,
434            mp4box_path: None,
435            temp_directory: None,
436            use_apple_silicon_encoder: None,
437            aac_encoder: default_aac_encoder(),
438        }
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn test_default_config() {
448        let config = Config::default();
449        assert_eq!(config.processing.parallel_workers, 2);
450        assert_eq!(config.quality.prefer_stereo, true);
451        assert_eq!(config.metadata.default_language, "es");
452    }
453
454    #[test]
455    fn test_config_serialization() {
456        let config = Config::default();
457        let yaml = serde_yaml::to_string(&config).unwrap();
458        let deserialized: Config = serde_yaml::from_str(&yaml).unwrap();
459        assert_eq!(deserialized.processing.parallel_workers, 2);
460    }
461}