use std::collections::HashMap;
use std::path::Path;
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::naming::CharacterSet;
use crate::vocab::{AudioFormat, SourceMode, StemFormat, VideoCoverRetention};
use super::label_to_env;
#[derive(Debug, Clone, Default, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Settings {
pub format: Option<AudioFormat>,
pub concurrency: Option<u32>,
pub retries: Option<u32>,
pub min_newest: Option<u32>,
pub token_command: Option<String>,
pub animated_covers: Option<bool>,
pub video_cover_retention: Option<VideoCoverRetention>,
pub animated_cover_quality: Option<u8>,
pub animated_cover_max_fps: Option<u32>,
pub animated_cover_max_width: Option<u32>,
pub animated_cover_compression_level: Option<u8>,
pub animated_cover_lossless: Option<bool>,
pub details_sidecar: Option<bool>,
pub lyrics_sidecar: Option<bool>,
pub lrc_sidecar: Option<bool>,
pub video_mp4: Option<bool>,
pub download_stems: Option<bool>,
pub stem_format: Option<StemFormat>,
pub naming_template: Option<String>,
pub character_set: Option<CharacterSet>,
pub number_singletons: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Defaults {
#[serde(flatten)]
pub settings: Settings,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SourceConfig {
#[serde(flatten)]
pub settings: Settings,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct AccountConfig {
pub token: Option<String>,
pub root: Option<String>,
pub account_id: Option<String>,
#[serde(flatten)]
pub settings: Settings,
#[serde(default)]
pub sources: HashMap<String, SourceConfig>,
pub areas: Option<AreasConfig>,
#[serde(default)]
pub albums: HashMap<String, String>,
#[serde(default)]
pub lead_tracks: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AreaMode {
Off,
Mode(SourceMode),
}
impl<'de> Deserialize<'de> for AreaMode {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
match raw.as_str() {
"off" => Ok(AreaMode::Off),
"copy" => Ok(AreaMode::Mode(SourceMode::Copy)),
"mirror" => Ok(AreaMode::Mode(SourceMode::Mirror)),
other => Err(serde::de::Error::custom(format!(
"unknown area mode '{other}', expected 'off', 'copy', or 'mirror'"
))),
}
}
}
#[cfg(feature = "schema")]
impl schemars::JsonSchema for AreaMode {
fn schema_name() -> std::borrow::Cow<'static, str> {
"AreaMode".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"enum": ["off", "copy", "mirror"],
"description": "Deletion mode for the library area: 'off' arms deletion of \
library-exclusive files, 'copy' is additive, 'mirror' deletes.",
})
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct AreasConfig {
pub library: Option<AreaMode>,
pub liked: Option<SourceMode>,
pub playlists: Option<SourceMode>,
#[serde(default)]
pub playlist: HashMap<String, SourceMode>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Config {
#[serde(default)]
pub defaults: Defaults,
#[serde(default)]
pub accounts: HashMap<String, AccountConfig>,
}
impl Config {
pub fn from_toml(toml_str: &str) -> Result<Self> {
let config: Self = toml::from_str(toml_str).map_err(|e| {
let raw = e.to_string();
let msg = raw
.lines()
.filter(|l| !l.contains(" | "))
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_owned();
Error::Config(if msg.is_empty() {
"parse error".into()
} else {
msg
})
})?;
config.validate()?;
Ok(config)
}
fn validate(&self) -> Result<()> {
let roots: Vec<(&str, &str)> = self
.accounts
.iter()
.filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
.collect();
for (i, (label_a, root_a)) in roots.iter().enumerate() {
for (label_b, root_b) in roots.iter().skip(i + 1) {
let a = Path::new(root_a);
let b = Path::new(root_b);
if a.starts_with(b) || b.starts_with(a) {
return Err(Error::Config(format!(
"account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
)));
}
}
}
let mut prefix_seen: HashMap<String, &str> = HashMap::new();
for label in self.accounts.keys() {
let prefix = label_to_env(label);
if let Some(other) = prefix_seen.get(&prefix) {
return Err(Error::Config(format!(
"accounts '{label}' and '{other}' share env prefix '{prefix}'"
)));
}
prefix_seen.insert(prefix, label.as_str());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_empty_toml() {
let cfg = Config::from_toml("").unwrap();
assert!(cfg.accounts.is_empty());
}
#[test]
fn parse_basic_account() {
let toml = r#"
[accounts.alice]
token = "tok"
root = "/music"
"#;
let cfg = Config::from_toml(toml).unwrap();
let acc = &cfg.accounts["alice"];
assert_eq!(acc.token.as_deref(), Some("tok"));
assert_eq!(acc.root.as_deref(), Some("/music"));
}
#[test]
fn parse_defaults_section() {
let toml = r#"
[defaults]
format = "mp3"
concurrency = 8
retries = 5
min_newest = 2
animated_covers = true
video_cover_retention = "both"
animated_cover_quality = 85
animated_cover_max_fps = 18
animated_cover_max_width = 720
animated_cover_compression_level = 4
"#;
let cfg = Config::from_toml(toml).unwrap();
assert_eq!(cfg.defaults.settings.format, Some(AudioFormat::Mp3));
assert_eq!(cfg.defaults.settings.concurrency, Some(8));
assert_eq!(cfg.defaults.settings.retries, Some(5));
assert_eq!(cfg.defaults.settings.min_newest, Some(2));
assert_eq!(cfg.defaults.settings.animated_covers, Some(true));
assert_eq!(
cfg.defaults.settings.video_cover_retention,
Some(VideoCoverRetention::Both)
);
assert_eq!(cfg.defaults.settings.animated_cover_quality, Some(85));
assert_eq!(cfg.defaults.settings.animated_cover_max_fps, Some(18));
assert_eq!(cfg.defaults.settings.animated_cover_max_width, Some(720));
assert_eq!(
cfg.defaults.settings.animated_cover_compression_level,
Some(4)
);
}
#[test]
fn validation_nested_roots() {
let toml = r#"
[accounts.alice]
root = "/music"
[accounts.bob]
root = "/music/bob"
"#;
assert!(Config::from_toml(toml).is_err());
}
#[test]
fn validation_non_nested_roots_ok() {
let toml = r#"
[accounts.alice]
root = "/music/alice"
[accounts.bob]
root = "/music/bob"
"#;
assert!(Config::from_toml(toml).is_ok());
}
#[test]
fn invalid_toml_errors() {
assert!(Config::from_toml("not valid toml ][").is_err());
}
#[test]
fn duplicate_account_label_errors() {
let toml = "
[accounts.alice]
token = \"tok1\"
[accounts.alice]
token = \"tok2\"
";
assert!(Config::from_toml(toml).is_err());
}
#[test]
fn parse_error_does_not_echo_token() {
let toml = "[accounts.alice]\ntoken = \"unterminated\n";
let err = Config::from_toml(toml).unwrap_err().to_string();
assert!(!err.contains("unterminated"), "error leaked token: {err}");
}
#[test]
fn validation_env_prefix_collision_errors() {
let toml = "
[accounts.my-lib]
[accounts.my_lib]
";
assert!(Config::from_toml(toml).is_err());
}
#[test]
fn areas_parse_full_table() {
let toml = r#"
[accounts.alice]
token = "t"
[accounts.alice.areas]
library = "off"
liked = "copy"
playlists = "mirror"
[accounts.alice.areas.playlist]
"pl_abc123" = "mirror"
"pl_def456" = "copy"
"#;
let cfg = Config::from_toml(toml).unwrap();
let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
assert_eq!(areas.library, Some(AreaMode::Off));
assert_eq!(areas.liked, Some(SourceMode::Copy));
assert_eq!(areas.playlists, Some(SourceMode::Mirror));
assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
}
#[test]
fn areas_library_accepts_copy_and_mirror() {
for (raw, expect) in [
("copy", AreaMode::Mode(SourceMode::Copy)),
("mirror", AreaMode::Mode(SourceMode::Mirror)),
] {
let toml =
format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
let cfg = Config::from_toml(&toml).unwrap();
assert_eq!(
cfg.accounts["a"].areas.as_ref().unwrap().library,
Some(expect)
);
}
}
#[test]
fn areas_bad_mode_errors() {
let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
assert!(Config::from_toml(toml).is_err());
}
#[test]
fn areas_bad_playlist_mode_errors() {
let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
assert!(Config::from_toml(toml).is_err());
}
#[test]
fn areas_unknown_field_errors() {
let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
assert!(Config::from_toml(toml).is_err());
}
#[test]
fn areas_absent_is_none() {
let toml = "[accounts.a]\ntoken = \"t\"\n";
assert!(
Config::from_toml(toml).unwrap().accounts["a"]
.areas
.is_none()
);
}
}