1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8#[derive(Debug, Error)]
9pub enum ConfigError {
10 #[error("io error: {0}")]
11 Io(#[from] std::io::Error),
12 #[error("parse error: {0}")]
13 Parse(#[from] toml::de::Error),
14 #[error("serialize error: {0}")]
15 Serialize(#[from] toml::ser::Error),
16}
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19#[serde(default)]
20pub struct Config {
21 pub library: LibraryConfig,
22 pub playback: PlaybackConfig,
23 pub remote: RemoteConfig,
24 pub organize: OrganizeConfig,
25 #[serde(alias = "visualiser")]
26 pub visualizer: VisualizerConfig,
27 pub radio: RadioConfig,
28 pub graphql: GraphqlConfig,
29 pub discovery: DiscoveryConfig,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(default)]
34pub struct LibraryConfig {
35 pub folders: Vec<PathBuf>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(default)]
40pub struct PlaybackConfig {
41 pub software_volume: bool,
42 pub replaygain: ReplayGainMode,
43 pub ticker_fps: u8,
46 pub target_fps: u8,
49 pub show_fps: bool,
51 pub pre_amp_db: f64,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub output_device: Option<String>,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum ReplayGainMode {
63 Off,
64 Track,
65 Album,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(default)]
70pub struct RemoteConfig {
71 pub enabled: bool,
72 pub url: String,
73 pub username: String,
74 #[serde(default, skip_serializing_if = "String::is_empty")]
76 pub password: String,
77 pub transcode_quality: String,
79 pub cache_dir: Option<PathBuf>,
81 pub download_workers: usize,
83}
84
85impl Default for LibraryConfig {
86 fn default() -> Self {
87 let music_dir = dirs::audio_dir().unwrap_or_else(|| {
88 dirs::home_dir()
89 .map(|h| h.join("Music"))
90 .unwrap_or_else(|| PathBuf::from("/Music"))
91 });
92 Self {
93 folders: vec![music_dir],
94 }
95 }
96}
97
98impl Default for PlaybackConfig {
99 fn default() -> Self {
100 Self {
101 software_volume: false,
102 replaygain: ReplayGainMode::Album,
103 ticker_fps: 8,
104 target_fps: 60,
105 show_fps: false,
106 pre_amp_db: 0.0,
107 output_device: None,
108 }
109 }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(default)]
114pub struct VisualizerConfig {
115 pub enabled: bool,
116 pub fps: u8,
117 pub scale: String,
119 pub amplitude_scale: String,
121 pub bar_decay_ms: u32,
123 pub peak_decay_ms: u32,
125}
126
127impl Default for VisualizerConfig {
128 fn default() -> Self {
129 Self {
130 enabled: true,
131 fps: 60,
132 scale: "bark".into(),
133 amplitude_scale: "aweight".into(),
134 bar_decay_ms: 50,
135 peak_decay_ms: 180,
136 }
137 }
138}
139
140impl Default for RemoteConfig {
141 fn default() -> Self {
142 Self {
143 enabled: false,
144 url: String::new(),
145 username: String::new(),
146 password: String::new(),
147 transcode_quality: "original".into(),
148 cache_dir: None,
149 download_workers: 5,
150 }
151 }
152}
153
154#[derive(Debug, Clone, Default, Serialize, Deserialize)]
155#[serde(default)]
156pub struct OrganizeConfig {
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub default: Option<String>,
160 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
162 pub patterns: HashMap<String, String>,
163}
164
165impl OrganizeConfig {
166 pub fn resolve_pattern<'a>(&'a self, name_or_raw: &'a str) -> &'a str {
169 self.patterns
170 .get(name_or_raw)
171 .map(|s| s.as_str())
172 .unwrap_or(name_or_raw)
173 }
174
175 pub fn default_pattern(&self) -> Option<&str> {
177 self.default
178 .as_ref()
179 .and_then(|name| self.patterns.get(name))
180 .map(|s| s.as_str())
181 }
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186#[serde(default)]
187pub struct GraphqlConfig {
188 pub enabled: bool,
191 pub port: u16,
193 pub playground: bool,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub subsonic_port: Option<u16>,
198}
199
200impl Default for GraphqlConfig {
201 fn default() -> Self {
202 Self {
203 enabled: true,
204 port: 4000,
205 playground: false,
206 subsonic_port: None,
207 }
208 }
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(default)]
214pub struct RadioConfig {
215 pub lookahead: usize,
217 pub batch_size: usize,
219 pub use_subsonic: bool,
221 pub history_window: usize,
223 pub seed_window: usize,
225 pub discovery_weight: f64,
228}
229
230impl Default for RadioConfig {
231 fn default() -> Self {
232 Self {
233 lookahead: 5,
234 batch_size: 5,
235 use_subsonic: true,
236 history_window: 200,
237 seed_window: 5,
238 discovery_weight: 0.3,
239 }
240 }
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
245#[serde(default)]
246pub struct DiscoveryConfig {
247 pub analysis_on_scan: bool,
249 pub acoustic_weight: f64,
251}
252
253impl Default for DiscoveryConfig {
254 fn default() -> Self {
255 Self {
256 analysis_on_scan: false,
257 acoustic_weight: 0.5,
258 }
259 }
260}
261
262impl Config {
263 pub fn load() -> Result<Self, ConfigError> {
267 let base_path = config_file_path();
268 let local_path = config_local_file_path();
269
270 let mut base_val: toml::Value = if base_path.exists() {
271 let contents = fs::read_to_string(&base_path)?;
272 toml::from_str(&contents)?
273 } else {
274 toml::Value::Table(toml::map::Map::new())
275 };
276
277 if local_path.exists() {
278 let local_contents = fs::read_to_string(&local_path)?;
279 let local_val: toml::Value = toml::from_str(&local_contents)?;
280 deep_merge(&mut base_val, local_val);
281 }
282
283 let config: Config = base_val.try_into()?;
284 Ok(config)
285 }
286
287 pub fn load_or_default() -> Self {
289 Self::load().unwrap_or_else(|e| {
290 log::warn!("failed to load config, using defaults: {}", e);
291 Self::default()
292 })
293 }
294
295 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
297 let contents = fs::read_to_string(path)?;
298 let config: Config = toml::from_str(&contents)?;
299 Ok(config)
300 }
301
302 pub fn save(&self) -> Result<(), ConfigError> {
304 let path = config_file_path();
305 if let Some(parent) = path.parent() {
306 fs::create_dir_all(parent)?;
307 }
308 let contents = toml::to_string_pretty(self)?;
309 fs::write(&path, contents)?;
310 Ok(())
311 }
312
313 pub fn save_local(&self) -> Result<(), ConfigError> {
315 let path = config_local_file_path();
316 if let Some(parent) = path.parent() {
317 fs::create_dir_all(parent)?;
318 }
319 let contents = toml::to_string_pretty(self)?;
320 fs::write(&path, contents)?;
321 #[cfg(unix)]
322 {
323 use std::os::unix::fs::PermissionsExt;
324 fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
325 }
326 Ok(())
327 }
328
329 pub fn cache_dir(&self) -> PathBuf {
331 self.remote
332 .cache_dir
333 .clone()
334 .unwrap_or_else(|| config_dir().join("cache"))
335 }
336}
337
338fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
341 match (base, overlay) {
342 (toml::Value::Table(base_map), toml::Value::Table(overlay_map)) => {
343 for (key, overlay_val) in overlay_map {
344 let entry = base_map
345 .entry(key)
346 .or_insert(toml::Value::Table(toml::map::Map::new()));
347 deep_merge(entry, overlay_val);
348 }
349 }
350 (base, overlay) => {
351 *base = overlay;
352 }
353 }
354}
355
356pub fn config_dir() -> PathBuf {
358 dirs::home_dir()
359 .unwrap_or_else(|| PathBuf::from("."))
360 .join(".config")
361 .join("koan")
362}
363
364pub fn config_file_path() -> PathBuf {
366 config_dir().join("config.toml")
367}
368
369pub fn config_local_file_path() -> PathBuf {
371 config_dir().join("config.local.toml")
372}
373
374pub fn db_path() -> PathBuf {
376 config_dir().join("koan.db")
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use std::fs;
383
384 fn tmp_dir() -> PathBuf {
385 let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
386 fs::create_dir_all(&dir).unwrap();
387 dir
388 }
389
390 #[test]
391 fn test_defaults() {
392 let cfg = Config::default();
393 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Album);
394 assert!(!cfg.remote.enabled);
395 assert_eq!(cfg.remote.transcode_quality, "original");
396 }
397
398 #[test]
399 fn test_roundtrip_toml() {
400 let cfg = Config::default();
401 let serialized = toml::to_string_pretty(&cfg).unwrap();
402 let deserialized: Config = toml::from_str(&serialized).unwrap();
403 assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
404 assert_eq!(
405 deserialized.remote.transcode_quality,
406 cfg.remote.transcode_quality
407 );
408 }
409
410 #[test]
411 fn test_load_from_file() {
412 let dir = tempfile::tempdir().unwrap();
413 let path = dir.path().join("config.toml");
414 fs::write(
415 &path,
416 r#"
417[library]
418folders = ["/tmp/music"]
419
420[playback]
421replaygain = "track"
422"#,
423 )
424 .unwrap();
425
426 let cfg = Config::load_from(&path).unwrap();
427 assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
428 assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
429 assert!(!cfg.remote.enabled);
430 }
431
432 #[test]
433 fn test_partial_toml_uses_defaults() {
434 let dir = tempfile::tempdir().unwrap();
435 let path = dir.path().join("partial.toml");
436 fs::write(&path, "[playback]\nsoftware_volume = true\n").unwrap();
437
438 let cfg = Config::load_from(&path).unwrap();
439 assert!(cfg.playback.software_volume);
440 }
441
442 #[test]
443 fn test_deep_merge_local_overrides_base() {
444 let base_toml = r#"
446[library]
447folders = ["/base/music"]
448
449[remote]
450url = "https://base.example.com"
451"#;
452 let local_toml = r#"
453[library]
454folders = ["/local/music"]
455
456[remote]
457enabled = true
458url = "https://local.example.com"
459username = "admin"
460"#;
461
462 let mut base_val: toml::Value = toml::from_str(base_toml).unwrap();
463 let local_val: toml::Value = toml::from_str(local_toml).unwrap();
464 deep_merge(&mut base_val, local_val);
465
466 let cfg: Config = base_val.try_into().unwrap();
467 assert_eq!(cfg.library.folders, vec![PathBuf::from("/local/music")]);
468 assert!(cfg.remote.enabled);
469 assert_eq!(cfg.remote.url, "https://local.example.com");
470 assert_eq!(cfg.remote.username, "admin");
471 }
472
473 #[test]
474 fn test_deep_merge_missing_keys_preserved() {
475 let base_toml = r#"
476[remote]
477url = "https://keep.me"
478username = "keepuser"
479"#;
480 let local_toml = r#"
482[remote]
483password = "secret"
484"#;
485
486 let mut base_val: toml::Value = toml::from_str(base_toml).unwrap();
487 let local_val: toml::Value = toml::from_str(local_toml).unwrap();
488 deep_merge(&mut base_val, local_val);
489
490 let cfg: Config = base_val.try_into().unwrap();
491 assert_eq!(cfg.remote.url, "https://keep.me");
492 assert_eq!(cfg.remote.username, "keepuser");
493 assert_eq!(cfg.remote.password, "secret");
494 }
495
496 #[test]
497 fn test_cache_dir_default() {
498 let cfg = Config::default();
499 assert!(cfg.cache_dir().ends_with("cache"));
500 }
501
502 #[test]
503 fn test_cache_dir_explicit() {
504 let mut cfg = Config::default();
505 cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
506 assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
507 }
508
509 #[test]
510 fn test_organize_config_defaults() {
511 let cfg = Config::default();
512 assert!(cfg.organize.default.is_none());
513 assert!(cfg.organize.patterns.is_empty());
514 }
515
516 #[test]
517 fn test_organize_config_from_toml() {
518 let dir = tmp_dir();
519 let path = dir.join("organize.toml");
520 fs::write(
521 &path,
522 r#"
523[organize]
524default = "standard"
525
526[organize.patterns]
527standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
528va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
529"#,
530 )
531 .unwrap();
532
533 let cfg = Config::load_from(&path).unwrap();
534 assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
535 assert_eq!(cfg.organize.patterns.len(), 2);
536 assert!(cfg.organize.patterns.contains_key("standard"));
537 assert!(cfg.organize.patterns.contains_key("va-aware"));
538
539 fs::remove_dir_all(&dir).ok();
540 }
541
542 #[test]
543 fn test_organize_resolve_named_pattern() {
544 let mut cfg = OrganizeConfig::default();
545 cfg.patterns
546 .insert("standard".into(), "%artist%/%title%".into());
547
548 assert_eq!(cfg.resolve_pattern("standard"), "%artist%/%title%");
549 assert_eq!(cfg.resolve_pattern("%raw%pattern%"), "%raw%pattern%");
551 }
552
553 #[test]
554 fn test_organize_default_pattern() {
555 let mut cfg = OrganizeConfig {
556 default: Some("standard".into()),
557 ..OrganizeConfig::default()
558 };
559 cfg.patterns
560 .insert("standard".into(), "%artist%/%title%".into());
561
562 assert_eq!(cfg.default_pattern(), Some("%artist%/%title%"));
563 }
564
565 #[test]
566 fn test_organize_default_pattern_missing_name() {
567 let cfg = OrganizeConfig {
568 default: Some("nonexistent".into()),
569 ..OrganizeConfig::default()
570 };
571 assert_eq!(cfg.default_pattern(), None);
573 }
574
575 #[test]
576 fn test_deep_merge_organize_patterns() {
577 let base_toml = r#"
578[organize]
579default = "standard"
580
581[organize.patterns]
582standard = "base-pattern"
583"#;
584 let local_toml = r#"
585[organize]
586default = "custom"
587
588[organize.patterns]
589custom = "local-pattern"
590"#;
591
592 let mut base_val: toml::Value = toml::from_str(base_toml).unwrap();
593 let local_val: toml::Value = toml::from_str(local_toml).unwrap();
594 deep_merge(&mut base_val, local_val);
595
596 let cfg: Config = base_val.try_into().unwrap();
597 assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
599 assert_eq!(cfg.organize.patterns.len(), 2);
601 assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
602 assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
603 }
604
605 #[test]
606 fn test_output_device_config_roundtrip() {
607 let mut cfg = Config::default();
608 cfg.playback.output_device = Some("My DAC".into());
609
610 let serialized = toml::to_string_pretty(&cfg).unwrap();
611 let deserialized: Config = toml::from_str(&serialized).unwrap();
612 assert_eq!(
613 deserialized.playback.output_device.as_deref(),
614 Some("My DAC")
615 );
616 }
617
618 #[test]
619 fn test_output_device_config_default_is_none() {
620 let cfg = Config::default();
621 assert!(cfg.playback.output_device.is_none());
622
623 let serialized = toml::to_string_pretty(&cfg).unwrap();
625 assert!(!serialized.contains("output_device"));
626 let deserialized: Config = toml::from_str(&serialized).unwrap();
627 assert!(deserialized.playback.output_device.is_none());
628 }
629
630 #[test]
631 fn test_output_device_config_from_toml() {
632 let dir = tempfile::tempdir().unwrap();
633 let path = dir.path().join("config.toml");
634 fs::write(
635 &path,
636 r#"
637[playback]
638output_device = "External Speakers"
639"#,
640 )
641 .unwrap();
642
643 let cfg = Config::load_from(&path).unwrap();
644 assert_eq!(
645 cfg.playback.output_device.as_deref(),
646 Some("External Speakers")
647 );
648 }
649
650 #[test]
651 fn test_organize_config_roundtrip() {
652 let mut cfg = Config::default();
653 cfg.organize.default = Some("standard".into());
654 cfg.organize
655 .patterns
656 .insert("standard".into(), "%artist%/%title%".into());
657
658 let serialized = toml::to_string_pretty(&cfg).unwrap();
659 let deserialized: Config = toml::from_str(&serialized).unwrap();
660 assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
661 assert_eq!(
662 deserialized.organize.patterns["standard"],
663 "%artist%/%title%"
664 );
665 }
666}