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