1use std::collections::HashMap;
7use std::path::Path;
8
9use serde::Deserialize;
10
11use crate::error::{Error, Result};
12use crate::naming::CharacterSet;
13use crate::vocab::{AudioFormat, SourceMode, StemFormat, VideoCoverRetention};
14
15use super::label_to_env;
16
17#[derive(Debug, Clone, Default, Deserialize)]
23pub struct Settings {
24 pub format: Option<AudioFormat>,
25 pub concurrency: Option<u32>,
26 pub retries: Option<u32>,
27 pub min_newest: Option<u32>,
28 pub token_command: Option<String>,
33 pub animated_covers: Option<bool>,
34 pub video_cover_retention: Option<VideoCoverRetention>,
35 pub animated_cover_quality: Option<u8>,
36 pub animated_cover_max_fps: Option<u32>,
37 pub animated_cover_max_width: Option<u32>,
38 pub animated_cover_compression_level: Option<u8>,
39 pub animated_cover_lossless: Option<bool>,
40 pub details_sidecar: Option<bool>,
41 pub lyrics_sidecar: Option<bool>,
42 pub lrc_sidecar: Option<bool>,
43 pub video_mp4: Option<bool>,
44 pub download_stems: Option<bool>,
45 pub stem_format: Option<StemFormat>,
46 pub naming_template: Option<String>,
47 pub character_set: Option<CharacterSet>,
48 pub number_singletons: Option<bool>,
52}
53
54#[derive(Debug, Clone, Default, Deserialize)]
56pub struct Defaults {
57 #[serde(flatten)]
58 pub settings: Settings,
59}
60
61#[derive(Debug, Clone, Default, Deserialize)]
63pub struct SourceConfig {
64 #[serde(flatten)]
65 pub settings: Settings,
66}
67
68#[derive(Debug, Clone, Default, Deserialize)]
70pub struct AccountConfig {
71 pub token: Option<String>,
72 pub root: Option<String>,
73 pub account_id: Option<String>,
77 #[serde(flatten)]
78 pub settings: Settings,
79 #[serde(default)]
80 pub sources: HashMap<String, SourceConfig>,
81 pub areas: Option<AreasConfig>,
84 #[serde(default)]
89 pub albums: HashMap<String, String>,
90 #[serde(default)]
95 pub lead_tracks: Vec<String>,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum AreaMode {
106 Off,
108 Mode(SourceMode),
110}
111
112impl<'de> Deserialize<'de> for AreaMode {
113 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
114 where
115 D: serde::Deserializer<'de>,
116 {
117 let raw = String::deserialize(deserializer)?;
118 match raw.as_str() {
119 "off" => Ok(AreaMode::Off),
120 "copy" => Ok(AreaMode::Mode(SourceMode::Copy)),
121 "mirror" => Ok(AreaMode::Mode(SourceMode::Mirror)),
122 other => Err(serde::de::Error::custom(format!(
123 "unknown area mode '{other}', expected 'off', 'copy', or 'mirror'"
124 ))),
125 }
126 }
127}
128
129#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct AreasConfig {
139 pub library: Option<AreaMode>,
140 pub liked: Option<SourceMode>,
141 pub playlists: Option<SourceMode>,
142 #[serde(default)]
143 pub playlist: HashMap<String, SourceMode>,
144}
145
146#[derive(Debug, Clone, Default, Deserialize)]
148pub struct Config {
149 #[serde(default)]
150 pub defaults: Defaults,
151 #[serde(default)]
152 pub accounts: HashMap<String, AccountConfig>,
153}
154
155impl Config {
156 pub fn from_toml(toml_str: &str) -> Result<Self> {
162 let config: Self = toml::from_str(toml_str).map_err(|e| {
163 let raw = e.to_string();
166 let msg = raw
167 .lines()
168 .filter(|l| !l.contains(" | "))
169 .collect::<Vec<_>>()
170 .join("\n")
171 .trim()
172 .to_owned();
173 Error::Config(if msg.is_empty() {
174 "parse error".into()
175 } else {
176 msg
177 })
178 })?;
179 config.validate()?;
180 Ok(config)
181 }
182
183 fn validate(&self) -> Result<()> {
184 let roots: Vec<(&str, &str)> = self
185 .accounts
186 .iter()
187 .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
188 .collect();
189
190 for (i, (label_a, root_a)) in roots.iter().enumerate() {
191 for (label_b, root_b) in roots.iter().skip(i + 1) {
192 let a = Path::new(root_a);
193 let b = Path::new(root_b);
194 if a.starts_with(b) || b.starts_with(a) {
195 return Err(Error::Config(format!(
196 "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
197 )));
198 }
199 }
200 }
201
202 let mut prefix_seen: HashMap<String, &str> = HashMap::new();
203 for label in self.accounts.keys() {
204 let prefix = label_to_env(label);
205 if let Some(other) = prefix_seen.get(&prefix) {
206 return Err(Error::Config(format!(
207 "accounts '{label}' and '{other}' share env prefix '{prefix}'"
208 )));
209 }
210 prefix_seen.insert(prefix, label.as_str());
211 }
212
213 Ok(())
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn parse_empty_toml() {
223 let cfg = Config::from_toml("").unwrap();
224 assert!(cfg.accounts.is_empty());
225 }
226
227 #[test]
228 fn parse_basic_account() {
229 let toml = r#"
230 [accounts.alice]
231 token = "tok"
232 root = "/music"
233 "#;
234 let cfg = Config::from_toml(toml).unwrap();
235 let acc = &cfg.accounts["alice"];
236 assert_eq!(acc.token.as_deref(), Some("tok"));
237 assert_eq!(acc.root.as_deref(), Some("/music"));
238 }
239
240 #[test]
241 fn parse_defaults_section() {
242 let toml = r#"
243 [defaults]
244 format = "mp3"
245 concurrency = 8
246 retries = 5
247 min_newest = 2
248 animated_covers = true
249 video_cover_retention = "both"
250 animated_cover_quality = 85
251 animated_cover_max_fps = 18
252 animated_cover_max_width = 720
253 animated_cover_compression_level = 4
254 "#;
255 let cfg = Config::from_toml(toml).unwrap();
256 assert_eq!(cfg.defaults.settings.format, Some(AudioFormat::Mp3));
257 assert_eq!(cfg.defaults.settings.concurrency, Some(8));
258 assert_eq!(cfg.defaults.settings.retries, Some(5));
259 assert_eq!(cfg.defaults.settings.min_newest, Some(2));
260 assert_eq!(cfg.defaults.settings.animated_covers, Some(true));
261 assert_eq!(
262 cfg.defaults.settings.video_cover_retention,
263 Some(VideoCoverRetention::Both)
264 );
265 assert_eq!(cfg.defaults.settings.animated_cover_quality, Some(85));
266 assert_eq!(cfg.defaults.settings.animated_cover_max_fps, Some(18));
267 assert_eq!(cfg.defaults.settings.animated_cover_max_width, Some(720));
268 assert_eq!(
269 cfg.defaults.settings.animated_cover_compression_level,
270 Some(4)
271 );
272 }
273
274 #[test]
275 fn validation_nested_roots() {
276 let toml = r#"
277 [accounts.alice]
278 root = "/music"
279
280 [accounts.bob]
281 root = "/music/bob"
282 "#;
283 assert!(Config::from_toml(toml).is_err());
284 }
285
286 #[test]
287 fn validation_non_nested_roots_ok() {
288 let toml = r#"
289 [accounts.alice]
290 root = "/music/alice"
291
292 [accounts.bob]
293 root = "/music/bob"
294 "#;
295 assert!(Config::from_toml(toml).is_ok());
296 }
297
298 #[test]
299 fn invalid_toml_errors() {
300 assert!(Config::from_toml("not valid toml ][").is_err());
301 }
302
303 #[test]
304 fn duplicate_account_label_errors() {
305 let toml = "
307 [accounts.alice]
308 token = \"tok1\"
309
310 [accounts.alice]
311 token = \"tok2\"
312 ";
313 assert!(Config::from_toml(toml).is_err());
314 }
315
316 #[test]
317 fn parse_error_does_not_echo_token() {
318 let toml = "[accounts.alice]\ntoken = \"unterminated\n";
320 let err = Config::from_toml(toml).unwrap_err().to_string();
321 assert!(!err.contains("unterminated"), "error leaked token: {err}");
322 }
323
324 #[test]
325 fn validation_env_prefix_collision_errors() {
326 let toml = "
328 [accounts.my-lib]
329 [accounts.my_lib]
330 ";
331 assert!(Config::from_toml(toml).is_err());
332 }
333
334 #[test]
335 fn areas_parse_full_table() {
336 let toml = r#"
337 [accounts.alice]
338 token = "t"
339 [accounts.alice.areas]
340 library = "off"
341 liked = "copy"
342 playlists = "mirror"
343 [accounts.alice.areas.playlist]
344 "pl_abc123" = "mirror"
345 "pl_def456" = "copy"
346 "#;
347 let cfg = Config::from_toml(toml).unwrap();
348 let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
349 assert_eq!(areas.library, Some(AreaMode::Off));
350 assert_eq!(areas.liked, Some(SourceMode::Copy));
351 assert_eq!(areas.playlists, Some(SourceMode::Mirror));
352 assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
353 assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
354 }
355
356 #[test]
357 fn areas_library_accepts_copy_and_mirror() {
358 for (raw, expect) in [
359 ("copy", AreaMode::Mode(SourceMode::Copy)),
360 ("mirror", AreaMode::Mode(SourceMode::Mirror)),
361 ] {
362 let toml =
363 format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
364 let cfg = Config::from_toml(&toml).unwrap();
365 assert_eq!(
366 cfg.accounts["a"].areas.as_ref().unwrap().library,
367 Some(expect)
368 );
369 }
370 }
371
372 #[test]
373 fn areas_bad_mode_errors() {
374 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
375 assert!(Config::from_toml(toml).is_err());
376 }
377
378 #[test]
379 fn areas_bad_playlist_mode_errors() {
380 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
381 assert!(Config::from_toml(toml).is_err());
383 }
384
385 #[test]
386 fn areas_unknown_field_errors() {
387 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
389 assert!(Config::from_toml(toml).is_err());
390 }
391
392 #[test]
393 fn areas_absent_is_none() {
394 let toml = "[accounts.a]\ntoken = \"t\"\n";
395 assert!(
396 Config::from_toml(toml).unwrap().accounts["a"]
397 .areas
398 .is_none()
399 );
400 }
401}