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() -> String {
136 "AreaMode".to_owned()
137 }
138
139 fn json_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
140 let values = ["off", "copy", "mirror"]
141 .into_iter()
142 .map(|s| serde_json::Value::String(s.to_owned()))
143 .collect();
144 schemars::schema::SchemaObject {
145 instance_type: Some(schemars::schema::InstanceType::String.into()),
146 enum_values: Some(values),
147 metadata: Some(Box::new(schemars::schema::Metadata {
148 description: Some(
149 "Deletion mode for the library area: 'off' arms deletion of \
150 library-exclusive files, 'copy' is additive, 'mirror' deletes."
151 .to_owned(),
152 ),
153 ..Default::default()
154 })),
155 ..Default::default()
156 }
157 .into()
158 }
159}
160
161#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
169#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
170#[serde(deny_unknown_fields)]
171pub struct AreasConfig {
172 pub library: Option<AreaMode>,
173 pub liked: Option<SourceMode>,
174 pub playlists: Option<SourceMode>,
175 #[serde(default)]
176 pub playlist: HashMap<String, SourceMode>,
177}
178
179#[derive(Debug, Clone, Default, Deserialize)]
181#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
182pub struct Config {
183 #[serde(default)]
184 pub defaults: Defaults,
185 #[serde(default)]
186 pub accounts: HashMap<String, AccountConfig>,
187}
188
189impl Config {
190 pub fn from_toml(toml_str: &str) -> Result<Self> {
196 let config: Self = toml::from_str(toml_str).map_err(|e| {
197 let raw = e.to_string();
200 let msg = raw
201 .lines()
202 .filter(|l| !l.contains(" | "))
203 .collect::<Vec<_>>()
204 .join("\n")
205 .trim()
206 .to_owned();
207 Error::Config(if msg.is_empty() {
208 "parse error".into()
209 } else {
210 msg
211 })
212 })?;
213 config.validate()?;
214 Ok(config)
215 }
216
217 fn validate(&self) -> Result<()> {
218 let roots: Vec<(&str, &str)> = self
219 .accounts
220 .iter()
221 .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
222 .collect();
223
224 for (i, (label_a, root_a)) in roots.iter().enumerate() {
225 for (label_b, root_b) in roots.iter().skip(i + 1) {
226 let a = Path::new(root_a);
227 let b = Path::new(root_b);
228 if a.starts_with(b) || b.starts_with(a) {
229 return Err(Error::Config(format!(
230 "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
231 )));
232 }
233 }
234 }
235
236 let mut prefix_seen: HashMap<String, &str> = HashMap::new();
237 for label in self.accounts.keys() {
238 let prefix = label_to_env(label);
239 if let Some(other) = prefix_seen.get(&prefix) {
240 return Err(Error::Config(format!(
241 "accounts '{label}' and '{other}' share env prefix '{prefix}'"
242 )));
243 }
244 prefix_seen.insert(prefix, label.as_str());
245 }
246
247 Ok(())
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn parse_empty_toml() {
257 let cfg = Config::from_toml("").unwrap();
258 assert!(cfg.accounts.is_empty());
259 }
260
261 #[test]
262 fn parse_basic_account() {
263 let toml = r#"
264 [accounts.alice]
265 token = "tok"
266 root = "/music"
267 "#;
268 let cfg = Config::from_toml(toml).unwrap();
269 let acc = &cfg.accounts["alice"];
270 assert_eq!(acc.token.as_deref(), Some("tok"));
271 assert_eq!(acc.root.as_deref(), Some("/music"));
272 }
273
274 #[test]
275 fn parse_defaults_section() {
276 let toml = r#"
277 [defaults]
278 format = "mp3"
279 concurrency = 8
280 retries = 5
281 min_newest = 2
282 animated_covers = true
283 video_cover_retention = "both"
284 animated_cover_quality = 85
285 animated_cover_max_fps = 18
286 animated_cover_max_width = 720
287 animated_cover_compression_level = 4
288 "#;
289 let cfg = Config::from_toml(toml).unwrap();
290 assert_eq!(cfg.defaults.settings.format, Some(AudioFormat::Mp3));
291 assert_eq!(cfg.defaults.settings.concurrency, Some(8));
292 assert_eq!(cfg.defaults.settings.retries, Some(5));
293 assert_eq!(cfg.defaults.settings.min_newest, Some(2));
294 assert_eq!(cfg.defaults.settings.animated_covers, Some(true));
295 assert_eq!(
296 cfg.defaults.settings.video_cover_retention,
297 Some(VideoCoverRetention::Both)
298 );
299 assert_eq!(cfg.defaults.settings.animated_cover_quality, Some(85));
300 assert_eq!(cfg.defaults.settings.animated_cover_max_fps, Some(18));
301 assert_eq!(cfg.defaults.settings.animated_cover_max_width, Some(720));
302 assert_eq!(
303 cfg.defaults.settings.animated_cover_compression_level,
304 Some(4)
305 );
306 }
307
308 #[test]
309 fn validation_nested_roots() {
310 let toml = r#"
311 [accounts.alice]
312 root = "/music"
313
314 [accounts.bob]
315 root = "/music/bob"
316 "#;
317 assert!(Config::from_toml(toml).is_err());
318 }
319
320 #[test]
321 fn validation_non_nested_roots_ok() {
322 let toml = r#"
323 [accounts.alice]
324 root = "/music/alice"
325
326 [accounts.bob]
327 root = "/music/bob"
328 "#;
329 assert!(Config::from_toml(toml).is_ok());
330 }
331
332 #[test]
333 fn invalid_toml_errors() {
334 assert!(Config::from_toml("not valid toml ][").is_err());
335 }
336
337 #[test]
338 fn duplicate_account_label_errors() {
339 let toml = "
341 [accounts.alice]
342 token = \"tok1\"
343
344 [accounts.alice]
345 token = \"tok2\"
346 ";
347 assert!(Config::from_toml(toml).is_err());
348 }
349
350 #[test]
351 fn parse_error_does_not_echo_token() {
352 let toml = "[accounts.alice]\ntoken = \"unterminated\n";
354 let err = Config::from_toml(toml).unwrap_err().to_string();
355 assert!(!err.contains("unterminated"), "error leaked token: {err}");
356 }
357
358 #[test]
359 fn validation_env_prefix_collision_errors() {
360 let toml = "
362 [accounts.my-lib]
363 [accounts.my_lib]
364 ";
365 assert!(Config::from_toml(toml).is_err());
366 }
367
368 #[test]
369 fn areas_parse_full_table() {
370 let toml = r#"
371 [accounts.alice]
372 token = "t"
373 [accounts.alice.areas]
374 library = "off"
375 liked = "copy"
376 playlists = "mirror"
377 [accounts.alice.areas.playlist]
378 "pl_abc123" = "mirror"
379 "pl_def456" = "copy"
380 "#;
381 let cfg = Config::from_toml(toml).unwrap();
382 let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
383 assert_eq!(areas.library, Some(AreaMode::Off));
384 assert_eq!(areas.liked, Some(SourceMode::Copy));
385 assert_eq!(areas.playlists, Some(SourceMode::Mirror));
386 assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
387 assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
388 }
389
390 #[test]
391 fn areas_library_accepts_copy_and_mirror() {
392 for (raw, expect) in [
393 ("copy", AreaMode::Mode(SourceMode::Copy)),
394 ("mirror", AreaMode::Mode(SourceMode::Mirror)),
395 ] {
396 let toml =
397 format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
398 let cfg = Config::from_toml(&toml).unwrap();
399 assert_eq!(
400 cfg.accounts["a"].areas.as_ref().unwrap().library,
401 Some(expect)
402 );
403 }
404 }
405
406 #[test]
407 fn areas_bad_mode_errors() {
408 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
409 assert!(Config::from_toml(toml).is_err());
410 }
411
412 #[test]
413 fn areas_bad_playlist_mode_errors() {
414 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
415 assert!(Config::from_toml(toml).is_err());
417 }
418
419 #[test]
420 fn areas_unknown_field_errors() {
421 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
423 assert!(Config::from_toml(toml).is_err());
424 }
425
426 #[test]
427 fn areas_absent_is_none() {
428 let toml = "[accounts.a]\ntoken = \"t\"\n";
429 assert!(
430 Config::from_toml(toml).unwrap().accounts["a"]
431 .areas
432 .is_none()
433 );
434 }
435}