use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("parse error: {0}")]
Parse(#[from] toml::de::Error),
#[error("serialize error: {0}")]
Serialize(#[from] toml::ser::Error),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub library: LibraryConfig,
pub playback: PlaybackConfig,
pub remote: RemoteConfig,
pub organize: OrganizeConfig,
#[serde(alias = "visualiser")]
pub visualizer: VisualizerConfig,
pub radio: RadioConfig,
pub graphql: GraphqlConfig,
pub discovery: DiscoveryConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LibraryConfig {
pub folders: Vec<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PlaybackConfig {
pub software_volume: bool,
pub replaygain: ReplayGainMode,
pub ticker_fps: u8,
pub target_fps: u8,
pub show_fps: bool,
pub pre_amp_db: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_device: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ReplayGainMode {
Off,
Track,
Album,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RemoteConfig {
pub enabled: bool,
pub url: String,
pub username: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub password: String,
pub transcode_quality: String,
pub cache_dir: Option<PathBuf>,
pub download_workers: usize,
}
impl Default for LibraryConfig {
fn default() -> Self {
let music_dir = dirs::audio_dir().unwrap_or_else(|| {
dirs::home_dir()
.map(|h| h.join("Music"))
.unwrap_or_else(|| PathBuf::from("/Music"))
});
Self {
folders: vec![music_dir],
}
}
}
impl Default for PlaybackConfig {
fn default() -> Self {
Self {
software_volume: false,
replaygain: ReplayGainMode::Album,
ticker_fps: 8,
target_fps: 60,
show_fps: false,
pre_amp_db: 0.0,
output_device: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct VisualizerConfig {
pub enabled: bool,
pub fps: u8,
pub scale: String,
pub amplitude_scale: String,
pub bar_decay_ms: u32,
pub peak_decay_ms: u32,
}
impl Default for VisualizerConfig {
fn default() -> Self {
Self {
enabled: true,
fps: 60,
scale: "bark".into(),
amplitude_scale: "aweight".into(),
bar_decay_ms: 50,
peak_decay_ms: 180,
}
}
}
impl Default for RemoteConfig {
fn default() -> Self {
Self {
enabled: false,
url: String::new(),
username: String::new(),
password: String::new(),
transcode_quality: "original".into(),
cache_dir: None,
download_workers: 5,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct OrganizeConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub patterns: HashMap<String, String>,
}
impl OrganizeConfig {
pub fn resolve_pattern<'a>(&'a self, name_or_raw: &'a str) -> &'a str {
self.patterns
.get(name_or_raw)
.map(|s| s.as_str())
.unwrap_or(name_or_raw)
}
pub fn default_pattern(&self) -> Option<&str> {
self.default
.as_ref()
.and_then(|name| self.patterns.get(name))
.map(|s| s.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct GraphqlConfig {
pub enabled: bool,
pub port: u16,
pub playground: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subsonic_port: Option<u16>,
}
impl Default for GraphqlConfig {
fn default() -> Self {
Self {
enabled: true,
port: 4000,
playground: false,
subsonic_port: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RadioConfig {
pub lookahead: usize,
pub batch_size: usize,
pub use_subsonic: bool,
pub history_window: usize,
pub seed_window: usize,
pub discovery_weight: f64,
}
impl Default for RadioConfig {
fn default() -> Self {
Self {
lookahead: 5,
batch_size: 5,
use_subsonic: true,
history_window: 200,
seed_window: 5,
discovery_weight: 0.3,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DiscoveryConfig {
pub analysis_on_scan: bool,
pub acoustic_weight: f64,
}
impl Default for DiscoveryConfig {
fn default() -> Self {
Self {
analysis_on_scan: false,
acoustic_weight: 0.5,
}
}
}
impl Config {
pub fn load() -> Result<Self, ConfigError> {
let base_path = config_file_path();
let local_path = config_local_file_path();
let mut base_val: toml::Value = if base_path.exists() {
let contents = fs::read_to_string(&base_path)?;
toml::from_str(&contents)?
} else {
toml::Value::Table(toml::map::Map::new())
};
if local_path.exists() {
let local_contents = fs::read_to_string(&local_path)?;
let local_val: toml::Value = toml::from_str(&local_contents)?;
deep_merge(&mut base_val, local_val);
}
let config: Config = base_val.try_into()?;
Ok(config)
}
pub fn load_or_default() -> Self {
Self::load().unwrap_or_else(|e| {
log::warn!("failed to load config, using defaults: {}", e);
Self::default()
})
}
pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
let contents = fs::read_to_string(path)?;
let config: Config = toml::from_str(&contents)?;
Ok(config)
}
pub fn save(&self) -> Result<(), ConfigError> {
let path = config_file_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(self)?;
fs::write(&path, contents)?;
Ok(())
}
pub fn save_local(&self) -> Result<(), ConfigError> {
let path = config_local_file_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(self)?;
fs::write(&path, contents)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
pub fn cache_dir(&self) -> PathBuf {
self.remote
.cache_dir
.clone()
.unwrap_or_else(|| config_dir().join("cache"))
}
}
fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
match (base, overlay) {
(toml::Value::Table(base_map), toml::Value::Table(overlay_map)) => {
for (key, overlay_val) in overlay_map {
let entry = base_map
.entry(key)
.or_insert(toml::Value::Table(toml::map::Map::new()));
deep_merge(entry, overlay_val);
}
}
(base, overlay) => {
*base = overlay;
}
}
}
pub fn config_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("koan")
}
pub fn config_file_path() -> PathBuf {
config_dir().join("config.toml")
}
pub fn config_local_file_path() -> PathBuf {
config_dir().join("config.local.toml")
}
pub fn db_path() -> PathBuf {
config_dir().join("koan.db")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tmp_dir() -> PathBuf {
let dir = std::env::temp_dir().join(format!("koan-test-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn test_defaults() {
let cfg = Config::default();
assert_eq!(cfg.playback.replaygain, ReplayGainMode::Album);
assert!(!cfg.remote.enabled);
assert_eq!(cfg.remote.transcode_quality, "original");
}
#[test]
fn test_roundtrip_toml() {
let cfg = Config::default();
let serialized = toml::to_string_pretty(&cfg).unwrap();
let deserialized: Config = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.playback.replaygain, cfg.playback.replaygain);
assert_eq!(
deserialized.remote.transcode_quality,
cfg.remote.transcode_quality
);
}
#[test]
fn test_load_from_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(
&path,
r#"
[library]
folders = ["/tmp/music"]
[playback]
replaygain = "track"
"#,
)
.unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(cfg.library.folders, vec![PathBuf::from("/tmp/music")]);
assert_eq!(cfg.playback.replaygain, ReplayGainMode::Track);
assert!(!cfg.remote.enabled);
}
#[test]
fn test_partial_toml_uses_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("partial.toml");
fs::write(&path, "[playback]\nsoftware_volume = true\n").unwrap();
let cfg = Config::load_from(&path).unwrap();
assert!(cfg.playback.software_volume);
}
#[test]
fn test_deep_merge_local_overrides_base() {
let base_toml = r#"
[library]
folders = ["/base/music"]
[remote]
url = "https://base.example.com"
"#;
let local_toml = r#"
[library]
folders = ["/local/music"]
[remote]
enabled = true
url = "https://local.example.com"
username = "admin"
"#;
let mut base_val: toml::Value = toml::from_str(base_toml).unwrap();
let local_val: toml::Value = toml::from_str(local_toml).unwrap();
deep_merge(&mut base_val, local_val);
let cfg: Config = base_val.try_into().unwrap();
assert_eq!(cfg.library.folders, vec![PathBuf::from("/local/music")]);
assert!(cfg.remote.enabled);
assert_eq!(cfg.remote.url, "https://local.example.com");
assert_eq!(cfg.remote.username, "admin");
}
#[test]
fn test_deep_merge_missing_keys_preserved() {
let base_toml = r#"
[remote]
url = "https://keep.me"
username = "keepuser"
"#;
let local_toml = r#"
[remote]
password = "secret"
"#;
let mut base_val: toml::Value = toml::from_str(base_toml).unwrap();
let local_val: toml::Value = toml::from_str(local_toml).unwrap();
deep_merge(&mut base_val, local_val);
let cfg: Config = base_val.try_into().unwrap();
assert_eq!(cfg.remote.url, "https://keep.me");
assert_eq!(cfg.remote.username, "keepuser");
assert_eq!(cfg.remote.password, "secret");
}
#[test]
fn test_cache_dir_default() {
let cfg = Config::default();
assert!(cfg.cache_dir().ends_with("cache"));
}
#[test]
fn test_cache_dir_explicit() {
let mut cfg = Config::default();
cfg.remote.cache_dir = Some(PathBuf::from("/custom/cache"));
assert_eq!(cfg.cache_dir(), PathBuf::from("/custom/cache"));
}
#[test]
fn test_organize_config_defaults() {
let cfg = Config::default();
assert!(cfg.organize.default.is_none());
assert!(cfg.organize.patterns.is_empty());
}
#[test]
fn test_organize_config_from_toml() {
let dir = tmp_dir();
let path = dir.join("organize.toml");
fs::write(
&path,
r#"
[organize]
default = "standard"
[organize.patterns]
standard = "%album artist%/(%date%) %album%/%tracknumber%. %title%"
va-aware = "%album artist%/$if($stricmp(%album artist%,Various Artists),,%album%)"
"#,
)
.unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(cfg.organize.default.as_deref(), Some("standard"));
assert_eq!(cfg.organize.patterns.len(), 2);
assert!(cfg.organize.patterns.contains_key("standard"));
assert!(cfg.organize.patterns.contains_key("va-aware"));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn test_organize_resolve_named_pattern() {
let mut cfg = OrganizeConfig::default();
cfg.patterns
.insert("standard".into(), "%artist%/%title%".into());
assert_eq!(cfg.resolve_pattern("standard"), "%artist%/%title%");
assert_eq!(cfg.resolve_pattern("%raw%pattern%"), "%raw%pattern%");
}
#[test]
fn test_organize_default_pattern() {
let mut cfg = OrganizeConfig {
default: Some("standard".into()),
..OrganizeConfig::default()
};
cfg.patterns
.insert("standard".into(), "%artist%/%title%".into());
assert_eq!(cfg.default_pattern(), Some("%artist%/%title%"));
}
#[test]
fn test_organize_default_pattern_missing_name() {
let cfg = OrganizeConfig {
default: Some("nonexistent".into()),
..OrganizeConfig::default()
};
assert_eq!(cfg.default_pattern(), None);
}
#[test]
fn test_deep_merge_organize_patterns() {
let base_toml = r#"
[organize]
default = "standard"
[organize.patterns]
standard = "base-pattern"
"#;
let local_toml = r#"
[organize]
default = "custom"
[organize.patterns]
custom = "local-pattern"
"#;
let mut base_val: toml::Value = toml::from_str(base_toml).unwrap();
let local_val: toml::Value = toml::from_str(local_toml).unwrap();
deep_merge(&mut base_val, local_val);
let cfg: Config = base_val.try_into().unwrap();
assert_eq!(cfg.organize.default.as_deref(), Some("custom"));
assert_eq!(cfg.organize.patterns.len(), 2);
assert_eq!(cfg.organize.patterns["standard"], "base-pattern");
assert_eq!(cfg.organize.patterns["custom"], "local-pattern");
}
#[test]
fn test_output_device_config_roundtrip() {
let mut cfg = Config::default();
cfg.playback.output_device = Some("My DAC".into());
let serialized = toml::to_string_pretty(&cfg).unwrap();
let deserialized: Config = toml::from_str(&serialized).unwrap();
assert_eq!(
deserialized.playback.output_device.as_deref(),
Some("My DAC")
);
}
#[test]
fn test_output_device_config_default_is_none() {
let cfg = Config::default();
assert!(cfg.playback.output_device.is_none());
let serialized = toml::to_string_pretty(&cfg).unwrap();
assert!(!serialized.contains("output_device"));
let deserialized: Config = toml::from_str(&serialized).unwrap();
assert!(deserialized.playback.output_device.is_none());
}
#[test]
fn test_output_device_config_from_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(
&path,
r#"
[playback]
output_device = "External Speakers"
"#,
)
.unwrap();
let cfg = Config::load_from(&path).unwrap();
assert_eq!(
cfg.playback.output_device.as_deref(),
Some("External Speakers")
);
}
#[test]
fn test_organize_config_roundtrip() {
let mut cfg = Config::default();
cfg.organize.default = Some("standard".into());
cfg.organize
.patterns
.insert("standard".into(), "%artist%/%title%".into());
let serialized = toml::to_string_pretty(&cfg).unwrap();
let deserialized: Config = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.organize.default.as_deref(), Some("standard"));
assert_eq!(
deserialized.organize.patterns["standard"],
"%artist%/%title%"
);
}
}