1use std::collections::{BTreeMap, HashMap};
7use std::fmt;
8use std::path::Path;
9use std::str::FromStr;
10
11use serde::{Deserialize, Serialize};
12
13use crate::error::{Error, Result};
14use crate::ffmpeg::WebpEncodeSettings;
15use crate::naming::CharacterSet;
16use crate::reconcile::SourceMode;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
20#[serde(rename_all = "lowercase")]
21pub enum AudioFormat {
22 Mp3,
23 #[default]
24 Flac,
25 Wav,
26 Alac,
27}
28
29impl AudioFormat {
30 pub fn ext(self) -> &'static str {
34 match self {
35 Self::Mp3 => "mp3",
36 Self::Flac => "flac",
37 Self::Wav => "wav",
38 Self::Alac => "m4a",
39 }
40 }
41}
42
43impl FromStr for AudioFormat {
44 type Err = Error;
45
46 fn from_str(s: &str) -> Result<Self> {
47 match s.to_ascii_lowercase().as_str() {
48 "mp3" => Ok(Self::Mp3),
49 "flac" => Ok(Self::Flac),
50 "wav" => Ok(Self::Wav),
51 "alac" => Ok(Self::Alac),
52 other => Err(Error::Config(format!("unknown format '{other}'"))),
53 }
54 }
55}
56
57impl fmt::Display for AudioFormat {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 match self {
60 Self::Mp3 => f.write_str("mp3"),
61 Self::Flac => f.write_str("flac"),
62 Self::Wav => f.write_str("wav"),
63 Self::Alac => f.write_str("alac"),
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
76#[serde(rename_all = "lowercase")]
77pub enum StemFormat {
78 #[default]
80 Wav,
81 Mp3,
83}
84
85impl StemFormat {
86 pub fn ext(self) -> &'static str {
88 match self {
89 Self::Wav => "wav",
90 Self::Mp3 => "mp3",
91 }
92 }
93}
94
95impl FromStr for StemFormat {
96 type Err = Error;
97
98 fn from_str(s: &str) -> Result<Self> {
99 match s.to_ascii_lowercase().as_str() {
100 "wav" => Ok(Self::Wav),
101 "mp3" => Ok(Self::Mp3),
102 "flac" => Err(Error::Config(
103 "stems cannot be stored as FLAC; use 'wav' or 'mp3'".to_string(),
104 )),
105 other => Err(Error::Config(format!("unknown stem format '{other}'"))),
106 }
107 }
108}
109
110impl fmt::Display for StemFormat {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 f.write_str(self.ext())
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
118#[serde(rename_all = "lowercase")]
119pub enum VideoCoverRetention {
120 #[default]
121 Neither,
122 Webp,
123 Mp4,
124 Both,
125}
126
127impl VideoCoverRetention {
128 pub fn keeps_webp(self) -> bool {
129 matches!(self, Self::Webp | Self::Both)
130 }
131
132 pub fn keeps_mp4(self) -> bool {
133 matches!(self, Self::Mp4 | Self::Both)
134 }
135}
136
137impl FromStr for VideoCoverRetention {
138 type Err = Error;
139
140 fn from_str(s: &str) -> Result<Self> {
141 match s.to_ascii_lowercase().as_str() {
142 "neither" => Ok(Self::Neither),
143 "webp" => Ok(Self::Webp),
144 "mp4" => Ok(Self::Mp4),
145 "both" => Ok(Self::Both),
146 other => Err(Error::Config(format!(
147 "unknown video_cover_retention '{other}'"
148 ))),
149 }
150 }
151}
152
153impl fmt::Display for VideoCoverRetention {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 match self {
156 Self::Neither => f.write_str("neither"),
157 Self::Webp => f.write_str("webp"),
158 Self::Mp4 => f.write_str("mp4"),
159 Self::Both => f.write_str("both"),
160 }
161 }
162}
163
164#[derive(Debug, Clone, Default, Deserialize)]
172pub struct Settings {
173 pub format: Option<AudioFormat>,
174 pub concurrency: Option<u32>,
175 pub retries: Option<u32>,
176 pub min_newest: Option<u32>,
177 pub token_command: Option<String>,
184 pub animated_covers: Option<bool>,
185 pub video_cover_retention: Option<VideoCoverRetention>,
186 pub animated_cover_quality: Option<u8>,
187 pub animated_cover_max_fps: Option<u32>,
188 pub animated_cover_max_width: Option<u32>,
189 pub animated_cover_compression_level: Option<u8>,
190 pub animated_cover_lossless: Option<bool>,
191 pub details_sidecar: Option<bool>,
192 pub lyrics_sidecar: Option<bool>,
193 pub lrc_sidecar: Option<bool>,
194 pub video_mp4: Option<bool>,
195 pub download_stems: Option<bool>,
196 pub stem_format: Option<StemFormat>,
197 pub naming_template: Option<String>,
198 pub character_set: Option<CharacterSet>,
199}
200
201#[derive(Debug, Clone, Default, Deserialize)]
203pub struct Defaults {
204 #[serde(flatten)]
205 pub settings: Settings,
206}
207
208#[derive(Debug, Clone, Default, Deserialize)]
210pub struct SourceConfig {
211 #[serde(flatten)]
212 pub settings: Settings,
213}
214
215#[derive(Debug, Clone, Default, Deserialize)]
217pub struct AccountConfig {
218 pub token: Option<String>,
219 pub root: Option<String>,
220 pub account_id: Option<String>,
224 #[serde(flatten)]
225 pub settings: Settings,
226 #[serde(default)]
227 pub sources: HashMap<String, SourceConfig>,
228 pub areas: Option<AreasConfig>,
231 #[serde(default)]
237 pub albums: HashMap<String, String>,
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum AreaMode {
248 Off,
250 Mode(SourceMode),
252}
253
254impl<'de> Deserialize<'de> for AreaMode {
255 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
256 where
257 D: serde::Deserializer<'de>,
258 {
259 let raw = String::deserialize(deserializer)?;
260 match raw.as_str() {
261 "off" => Ok(AreaMode::Off),
262 "copy" => Ok(AreaMode::Mode(SourceMode::Copy)),
263 "mirror" => Ok(AreaMode::Mode(SourceMode::Mirror)),
264 other => Err(serde::de::Error::custom(format!(
265 "unknown area mode '{other}', expected 'off', 'copy', or 'mirror'"
266 ))),
267 }
268 }
269}
270
271#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
280#[serde(deny_unknown_fields)]
281pub struct AreasConfig {
282 pub library: Option<AreaMode>,
283 pub liked: Option<SourceMode>,
284 pub playlists: Option<SourceMode>,
285 #[serde(default)]
286 pub playlist: HashMap<String, SourceMode>,
287}
288
289#[derive(Debug, Clone, Default, Deserialize)]
291pub struct Config {
292 #[serde(default)]
293 pub defaults: Defaults,
294 #[serde(default)]
295 pub accounts: HashMap<String, AccountConfig>,
296}
297
298impl Config {
299 pub fn from_toml(toml_str: &str) -> Result<Self> {
305 let config: Self = toml::from_str(toml_str).map_err(|e| {
306 let raw = e.to_string();
309 let msg = raw
310 .lines()
311 .filter(|l| !l.contains(" | "))
312 .collect::<Vec<_>>()
313 .join("\n")
314 .trim()
315 .to_owned();
316 Error::Config(if msg.is_empty() {
317 "parse error".into()
318 } else {
319 msg
320 })
321 })?;
322 config.validate()?;
323 Ok(config)
324 }
325
326 fn validate(&self) -> Result<()> {
327 let roots: Vec<(&str, &str)> = self
328 .accounts
329 .iter()
330 .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
331 .collect();
332
333 for (i, (label_a, root_a)) in roots.iter().enumerate() {
334 for (label_b, root_b) in roots.iter().skip(i + 1) {
335 let a = Path::new(root_a);
336 let b = Path::new(root_b);
337 if a.starts_with(b) || b.starts_with(a) {
338 return Err(Error::Config(format!(
339 "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
340 )));
341 }
342 }
343 }
344
345 let mut prefix_seen: HashMap<String, &str> = HashMap::new();
346 for label in self.accounts.keys() {
347 let prefix = label_to_env(label);
348 if let Some(other) = prefix_seen.get(&prefix) {
349 return Err(Error::Config(format!(
350 "accounts '{label}' and '{other}' share env prefix '{prefix}'"
351 )));
352 }
353 prefix_seen.insert(prefix, label.as_str());
354 }
355
356 Ok(())
357 }
358
359 pub fn resolve(
365 &self,
366 account: &str,
367 source: Option<&str>,
368 env: &HashMap<String, String>,
369 flags: &FlagOverrides,
370 ) -> Result<EffectiveSettings> {
371 let acc = self
372 .accounts
373 .get(account)
374 .ok_or_else(|| Error::Config(format!("account '{account}' not found")))?;
375
376 let src = source.and_then(|s| acc.sources.get(s));
377 let label_env = label_to_env(account);
378
379 let env_val = |suffix: &str| -> Option<&str> {
381 env.get(&format!("SUNO_{label_env}_{suffix}"))
382 .or_else(|| env.get(&format!("SUNO_{suffix}")))
383 .map(String::as_str)
384 };
385
386 let format_from_env = env_val("FORMAT")
387 .map(str::parse::<AudioFormat>)
388 .transpose()?;
389
390 let format = flags
391 .settings
392 .format
393 .or(format_from_env)
394 .or_else(|| src.and_then(|s| s.settings.format))
395 .or(acc.settings.format)
396 .or(self.defaults.settings.format)
397 .unwrap_or(AudioFormat::Flac);
398
399 let concurrency = resolve_parsed(
400 flags.settings.concurrency,
401 env_val("CONCURRENCY"),
402 src.and_then(|s| s.settings.concurrency),
403 acc.settings.concurrency,
404 self.defaults.settings.concurrency,
405 4,
406 "CONCURRENCY",
407 )?;
408
409 let retries = resolve_parsed(
410 flags.settings.retries,
411 env_val("RETRIES"),
412 src.and_then(|s| s.settings.retries),
413 acc.settings.retries,
414 self.defaults.settings.retries,
415 3,
416 "RETRIES",
417 )?;
418
419 let min_newest = resolve_parsed(
420 flags.settings.min_newest,
421 env_val("MIN_NEWEST"),
422 src.and_then(|s| s.settings.min_newest),
423 acc.settings.min_newest,
424 self.defaults.settings.min_newest,
425 1,
426 "MIN_NEWEST",
427 )?;
428
429 let animated_covers = resolve_parsed(
430 flags.settings.animated_covers,
431 env_val("ANIMATED_COVERS"),
432 src.and_then(|s| s.settings.animated_covers),
433 acc.settings.animated_covers,
434 self.defaults.settings.animated_covers,
435 false,
436 "ANIMATED_COVERS",
437 )?;
438
439 let details_sidecar = resolve_parsed(
440 flags.settings.details_sidecar,
441 env_val("DETAILS_SIDECAR"),
442 src.and_then(|s| s.settings.details_sidecar),
443 acc.settings.details_sidecar,
444 self.defaults.settings.details_sidecar,
445 false,
446 "DETAILS_SIDECAR",
447 )?;
448
449 let lyrics_sidecar = resolve_parsed(
450 flags.settings.lyrics_sidecar,
451 env_val("LYRICS_SIDECAR"),
452 src.and_then(|s| s.settings.lyrics_sidecar),
453 acc.settings.lyrics_sidecar,
454 self.defaults.settings.lyrics_sidecar,
455 false,
456 "LYRICS_SIDECAR",
457 )?;
458
459 let lrc_sidecar = resolve_parsed(
460 flags.settings.lrc_sidecar,
461 env_val("LRC_SIDECAR"),
462 src.and_then(|s| s.settings.lrc_sidecar),
463 acc.settings.lrc_sidecar,
464 self.defaults.settings.lrc_sidecar,
465 false,
466 "LRC_SIDECAR",
467 )?;
468
469 let video_mp4 = resolve_parsed(
470 flags.settings.video_mp4,
471 env_val("VIDEO_MP4"),
472 src.and_then(|s| s.settings.video_mp4),
473 acc.settings.video_mp4,
474 self.defaults.settings.video_mp4,
475 false,
476 "VIDEO_MP4",
477 )?;
478
479 let download_stems = resolve_parsed(
480 flags.settings.download_stems,
481 env_val("DOWNLOAD_STEMS"),
482 src.and_then(|s| s.settings.download_stems),
483 acc.settings.download_stems,
484 self.defaults.settings.download_stems,
485 false,
486 "DOWNLOAD_STEMS",
487 )?;
488
489 let stem_format_from_env = env_val("STEM_FORMAT")
490 .map(str::parse::<StemFormat>)
491 .transpose()?;
492 let stem_format = flags
493 .settings
494 .stem_format
495 .or(stem_format_from_env)
496 .or_else(|| src.and_then(|s| s.settings.stem_format))
497 .or(acc.settings.stem_format)
498 .or(self.defaults.settings.stem_format)
499 .unwrap_or_default();
500
501 let video_cover_retention = resolve_enum(
502 flags.settings.video_cover_retention,
503 env_val("VIDEO_COVER_RETENTION"),
504 src.and_then(|s| s.settings.video_cover_retention),
505 acc.settings.video_cover_retention,
506 self.defaults.settings.video_cover_retention,
507 None,
508 "VIDEO_COVER_RETENTION",
509 )?;
510 let (animated_covers, raw_animated_cover) = match video_cover_retention {
517 Some(retention) => (retention.keeps_webp(), retention.keeps_mp4()),
518 None => (animated_covers, false),
519 };
520
521 let defaults_webp = WebpEncodeSettings::default();
522 let animated_cover_quality = resolve_u8_ranged(
523 flags.settings.animated_cover_quality,
524 env_val("ANIMATED_COVER_QUALITY"),
525 src.and_then(|s| s.settings.animated_cover_quality),
526 acc.settings.animated_cover_quality,
527 self.defaults.settings.animated_cover_quality,
528 defaults_webp.quality,
529 "ANIMATED_COVER_QUALITY",
530 0..=100,
531 )?;
532 let animated_cover_max_fps = resolve_parsed(
533 flags.settings.animated_cover_max_fps,
534 env_val("ANIMATED_COVER_MAX_FPS"),
535 src.and_then(|s| s.settings.animated_cover_max_fps),
536 acc.settings.animated_cover_max_fps,
537 self.defaults.settings.animated_cover_max_fps,
538 defaults_webp.max_fps,
539 "ANIMATED_COVER_MAX_FPS",
540 )?;
541 let animated_cover_max_width_from_env = env_val("ANIMATED_COVER_MAX_WIDTH")
542 .map(|s| {
543 s.parse().map_err(|_| {
544 Error::Config(format!(
545 "invalid ANIMATED_COVER_MAX_WIDTH: '{s}' (expected integer)"
546 ))
547 })
548 })
549 .transpose()?;
550 let animated_cover_max_width = if let Some(v) = flags.settings.animated_cover_max_width {
551 Some(v)
552 } else if let Some(v) = animated_cover_max_width_from_env {
553 Some(v)
554 } else {
555 src.and_then(|s| s.settings.animated_cover_max_width)
556 .or(acc.settings.animated_cover_max_width)
557 .or(self.defaults.settings.animated_cover_max_width)
558 .or(defaults_webp.max_width)
559 };
560 let animated_cover_compression_level = resolve_u8_ranged(
561 flags.settings.animated_cover_compression_level,
562 env_val("ANIMATED_COVER_COMPRESSION_LEVEL"),
563 src.and_then(|s| s.settings.animated_cover_compression_level),
564 acc.settings.animated_cover_compression_level,
565 self.defaults.settings.animated_cover_compression_level,
566 defaults_webp.compression_level,
567 "ANIMATED_COVER_COMPRESSION_LEVEL",
568 0..=4,
569 )?;
570 let animated_cover_lossless = resolve_parsed(
571 flags.settings.animated_cover_lossless,
572 env_val("ANIMATED_COVER_LOSSLESS"),
573 src.and_then(|s| s.settings.animated_cover_lossless),
574 acc.settings.animated_cover_lossless,
575 self.defaults.settings.animated_cover_lossless,
576 defaults_webp.lossless,
577 "ANIMATED_COVER_LOSSLESS",
578 )?;
579
580 let naming_template_from_env = env_val("NAMING_TEMPLATE").map(str::to_owned);
581 let naming_template = flags
582 .settings
583 .naming_template
584 .clone()
585 .or(naming_template_from_env)
586 .or_else(|| src.and_then(|s| s.settings.naming_template.clone()))
587 .or_else(|| acc.settings.naming_template.clone())
588 .or_else(|| self.defaults.settings.naming_template.clone())
589 .unwrap_or_else(|| crate::naming::DEFAULT_TEMPLATE.to_owned());
590
591 let character_set_from_env = env_val("CHARACTER_SET")
592 .map(str::parse::<CharacterSet>)
593 .transpose()?;
594 let character_set = flags
595 .settings
596 .character_set
597 .or(character_set_from_env)
598 .or_else(|| src.and_then(|s| s.settings.character_set))
599 .or(acc.settings.character_set)
600 .or(self.defaults.settings.character_set)
601 .unwrap_or(CharacterSet::Unicode);
602
603 let token = flags
604 .token
605 .clone()
606 .or_else(|| env.get(&format!("SUNO_{label_env}_TOKEN")).cloned())
607 .or_else(|| env.get("SUNO_TOKEN").cloned());
608
609 let token_command = env
610 .get(&format!("SUNO_{label_env}_TOKEN_COMMAND"))
611 .cloned()
612 .or_else(|| env.get("SUNO_TOKEN_COMMAND").cloned())
613 .or_else(|| src.and_then(|s| s.settings.token_command.clone()))
614 .or_else(|| acc.settings.token_command.clone())
615 .or_else(|| self.defaults.settings.token_command.clone());
616
617 Ok(EffectiveSettings {
618 token,
619 stored_token: acc.token.clone(),
620 token_command,
621 account_id: acc.account_id.clone(),
622 format,
623 concurrency,
624 retries,
625 min_newest,
626 animated_covers,
627 raw_animated_cover,
628 video_cover_retention: match (animated_covers, raw_animated_cover) {
629 (false, false) => VideoCoverRetention::Neither,
630 (true, false) => VideoCoverRetention::Webp,
631 (false, true) => VideoCoverRetention::Mp4,
632 (true, true) => VideoCoverRetention::Both,
633 },
634 animated_cover_webp: WebpEncodeSettings {
635 quality: animated_cover_quality,
636 max_fps: animated_cover_max_fps,
637 max_width: animated_cover_max_width,
638 lossless: animated_cover_lossless,
639 compression_level: animated_cover_compression_level,
640 },
641 details_sidecar,
642 lyrics_sidecar,
643 lrc_sidecar,
644 video_mp4,
645 download_stems,
646 stem_format,
647 naming_template,
648 character_set,
649 areas: acc.areas.clone(),
650 album_overrides: acc
651 .albums
652 .iter()
653 .filter(|(_, name)| !name.trim().is_empty())
654 .map(|(root_id, name)| (root_id.clone(), name.trim().to_owned()))
655 .collect(),
656 })
657 }
658}
659
660fn resolve_parsed<T>(
661 flag: Option<T>,
662 env_str: Option<&str>,
663 src: Option<T>,
664 acc: Option<T>,
665 defaults: Option<T>,
666 compiled: T,
667 name: &str,
668) -> Result<T>
669where
670 T: FromStr + Copy,
671{
672 if let Some(v) = flag {
673 return Ok(v);
674 }
675 if let Some(s) = env_str {
676 return s
677 .parse()
678 .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
679 }
680 Ok(src.or(acc).or(defaults).unwrap_or(compiled))
681}
682
683#[allow(clippy::too_many_arguments)]
684fn resolve_u8_ranged(
685 flag: Option<u8>,
686 env_str: Option<&str>,
687 src: Option<u8>,
688 acc: Option<u8>,
689 defaults: Option<u8>,
690 compiled: u8,
691 name: &str,
692 range: std::ops::RangeInclusive<u8>,
693) -> Result<u8> {
694 let value = if let Some(v) = flag {
695 v
696 } else if let Some(s) = env_str {
697 s.parse()
698 .map_err(|_| Error::Config(format!("invalid {name}: '{s}' (expected integer)")))?
699 } else {
700 src.or(acc).or(defaults).unwrap_or(compiled)
701 };
702 if range.contains(&value) {
703 Ok(value)
704 } else {
705 Err(Error::Config(format!(
706 "invalid {name}: '{value}' (expected {}..={})",
707 range.start(),
708 range.end()
709 )))
710 }
711}
712
713fn resolve_enum<T>(
714 flag: Option<T>,
715 env_str: Option<&str>,
716 src: Option<T>,
717 acc: Option<T>,
718 defaults: Option<T>,
719 compiled: Option<T>,
720 name: &str,
721) -> Result<Option<T>>
722where
723 T: FromStr<Err = Error> + Copy,
724{
725 if let Some(v) = flag {
726 return Ok(Some(v));
727 }
728 if let Some(s) = env_str {
729 return s
730 .parse()
731 .map(Some)
732 .map_err(|err| Error::Config(format!("invalid {name}: '{s}' ({err})")));
733 }
734 Ok(src.or(acc).or(defaults).or(compiled))
735}
736
737pub fn label_to_env(label: &str) -> String {
741 label.to_ascii_uppercase().replace('-', "_")
742}
743
744#[derive(Debug, Default)]
752pub struct FlagOverrides {
753 pub token: Option<String>,
754 pub settings: Settings,
755}
756
757#[derive(Debug, Clone, PartialEq)]
759pub struct EffectiveSettings {
760 pub token: Option<String>,
762 pub stored_token: Option<String>,
764 pub token_command: Option<String>,
766 pub account_id: Option<String>,
768 pub format: AudioFormat,
769 pub concurrency: u32,
770 pub retries: u32,
771 pub min_newest: u32,
772 pub animated_covers: bool,
773 pub raw_animated_cover: bool,
776 pub video_cover_retention: VideoCoverRetention,
777 pub animated_cover_webp: WebpEncodeSettings,
778 pub details_sidecar: bool,
779 pub lyrics_sidecar: bool,
780 pub lrc_sidecar: bool,
781 pub video_mp4: bool,
782 pub download_stems: bool,
783 pub stem_format: StemFormat,
784 pub naming_template: String,
785 pub character_set: CharacterSet,
786 pub areas: Option<AreasConfig>,
788 pub album_overrides: BTreeMap<String, String>,
792}
793
794impl EffectiveSettings {
795 pub fn requires_ffmpeg(&self) -> bool {
803 matches!(self.format, AudioFormat::Flac | AudioFormat::Alac) || self.animated_covers
804 }
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810
811 fn no_env() -> HashMap<String, String> {
812 HashMap::new()
813 }
814
815 fn no_flags() -> FlagOverrides {
816 FlagOverrides::default()
817 }
818
819 #[test]
820 fn parse_empty_toml() {
821 let cfg = Config::from_toml("").unwrap();
822 assert!(cfg.accounts.is_empty());
823 }
824
825 #[test]
826 fn parse_basic_account() {
827 let toml = r#"
828 [accounts.alice]
829 token = "tok"
830 root = "/music"
831 "#;
832 let cfg = Config::from_toml(toml).unwrap();
833 let acc = &cfg.accounts["alice"];
834 assert_eq!(acc.token.as_deref(), Some("tok"));
835 assert_eq!(acc.root.as_deref(), Some("/music"));
836 }
837
838 #[test]
839 fn account_id_parses_and_resolves() {
840 let toml = r#"
841 [accounts.alice]
842 token = "tok"
843 root = "/music"
844 account_id = "user_abc123"
845 "#;
846 let cfg = Config::from_toml(toml).unwrap();
847 assert_eq!(
848 cfg.accounts["alice"].account_id.as_deref(),
849 Some("user_abc123")
850 );
851 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
852 assert_eq!(eff.account_id.as_deref(), Some("user_abc123"));
853 }
854
855 #[test]
856 fn parse_defaults_section() {
857 let toml = r#"
858 [defaults]
859 format = "mp3"
860 concurrency = 8
861 retries = 5
862 min_newest = 2
863 animated_covers = true
864 video_cover_retention = "both"
865 animated_cover_quality = 85
866 animated_cover_max_fps = 18
867 animated_cover_max_width = 720
868 animated_cover_compression_level = 4
869 "#;
870 let cfg = Config::from_toml(toml).unwrap();
871 assert_eq!(cfg.defaults.settings.format, Some(AudioFormat::Mp3));
872 assert_eq!(cfg.defaults.settings.concurrency, Some(8));
873 assert_eq!(cfg.defaults.settings.retries, Some(5));
874 assert_eq!(cfg.defaults.settings.min_newest, Some(2));
875 assert_eq!(cfg.defaults.settings.animated_covers, Some(true));
876 assert_eq!(
877 cfg.defaults.settings.video_cover_retention,
878 Some(VideoCoverRetention::Both)
879 );
880 assert_eq!(cfg.defaults.settings.animated_cover_quality, Some(85));
881 assert_eq!(cfg.defaults.settings.animated_cover_max_fps, Some(18));
882 assert_eq!(cfg.defaults.settings.animated_cover_max_width, Some(720));
883 assert_eq!(
884 cfg.defaults.settings.animated_cover_compression_level,
885 Some(4)
886 );
887 }
888
889 #[test]
890 fn compiled_defaults_when_nothing_set() {
891 let toml = "[accounts.alice]\n";
892 let cfg = Config::from_toml(toml).unwrap();
893 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
894 assert_eq!(
895 eff,
896 EffectiveSettings {
897 token: None,
898 stored_token: None,
899 token_command: None,
900 account_id: None,
901 format: AudioFormat::Flac,
902 concurrency: 4,
903 retries: 3,
904 min_newest: 1,
905 animated_covers: false,
906 raw_animated_cover: false,
907 video_cover_retention: VideoCoverRetention::Neither,
908 animated_cover_webp: WebpEncodeSettings::default(),
909 details_sidecar: false,
910 lyrics_sidecar: false,
911 lrc_sidecar: false,
912 video_mp4: false,
913 download_stems: false,
914 stem_format: StemFormat::Wav,
915 naming_template: crate::naming::DEFAULT_TEMPLATE.to_owned(),
916 character_set: CharacterSet::Unicode,
917 areas: None,
918 album_overrides: BTreeMap::new(),
919 }
920 );
921 }
922
923 #[test]
933 fn resolve_reflects_every_settings_field() {
934 let sentinel = Settings {
935 format: Some(AudioFormat::Mp3),
936 concurrency: Some(99),
937 retries: Some(98),
938 min_newest: Some(42),
939 token_command: Some("sentinel-token-cmd".into()),
940 animated_covers: Some(true),
941 video_cover_retention: Some(VideoCoverRetention::Both),
942 animated_cover_quality: Some(77),
943 animated_cover_max_fps: Some(13),
944 animated_cover_max_width: Some(333),
945 animated_cover_compression_level: Some(3),
946 animated_cover_lossless: Some(true),
947 details_sidecar: Some(true),
948 lyrics_sidecar: Some(true),
949 lrc_sidecar: Some(true),
950 video_mp4: Some(true),
951 download_stems: Some(true),
952 stem_format: Some(StemFormat::Mp3),
953 naming_template: Some("SENTINEL/{id}".into()),
954 character_set: Some(CharacterSet::Ascii),
955 };
956 let cfg = Config {
957 defaults: Defaults { settings: sentinel },
958 accounts: HashMap::from([("alice".to_owned(), AccountConfig::default())]),
959 };
960
961 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
962
963 assert_eq!(eff.format, AudioFormat::Mp3);
964 assert_eq!(eff.concurrency, 99);
965 assert_eq!(eff.retries, 98);
966 assert_eq!(eff.min_newest, 42);
967 assert_eq!(eff.token_command.as_deref(), Some("sentinel-token-cmd"));
968 assert!(eff.animated_covers);
970 assert!(eff.raw_animated_cover);
971 assert_eq!(eff.video_cover_retention, VideoCoverRetention::Both);
972 assert_eq!(eff.animated_cover_webp.quality, 77);
973 assert_eq!(eff.animated_cover_webp.max_fps, 13);
974 assert_eq!(eff.animated_cover_webp.max_width, Some(333));
975 assert_eq!(eff.animated_cover_webp.compression_level, 3);
976 assert!(eff.animated_cover_webp.lossless);
977 assert!(eff.details_sidecar);
978 assert!(eff.lyrics_sidecar);
979 assert!(eff.lrc_sidecar);
980 assert!(eff.video_mp4);
981 assert!(eff.download_stems);
982 assert_eq!(eff.stem_format, StemFormat::Mp3);
983 assert_eq!(eff.naming_template, "SENTINEL/{id}");
984 assert_eq!(eff.character_set, CharacterSet::Ascii);
985 }
986
987 #[test]
988 fn file_defaults_override_compiled() {
989 let toml = r#"
990 [defaults]
991 format = "mp3"
992 concurrency = 8
993
994 [accounts.alice]
995 "#;
996 let cfg = Config::from_toml(toml).unwrap();
997 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
998 assert_eq!(eff.format, AudioFormat::Mp3);
999 assert_eq!(eff.concurrency, 8);
1000 assert_eq!(eff.retries, 3); }
1002
1003 #[test]
1004 fn account_settings_override_defaults() {
1005 let toml = r#"
1006 [defaults]
1007 format = "mp3"
1008
1009 [accounts.alice]
1010 format = "wav"
1011 "#;
1012 let cfg = Config::from_toml(toml).unwrap();
1013 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1014 assert_eq!(eff.format, AudioFormat::Wav);
1015 }
1016
1017 #[test]
1018 fn per_source_overrides_account() {
1019 let toml = r#"
1020 [accounts.alice]
1021 format = "flac"
1022
1023 [accounts.alice.sources.liked]
1024 format = "mp3"
1025 "#;
1026 let cfg = Config::from_toml(toml).unwrap();
1027 let eff = cfg
1028 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1029 .unwrap();
1030 assert_eq!(eff.format, AudioFormat::Mp3);
1031 }
1032
1033 #[test]
1034 fn unknown_source_falls_back_to_account() {
1035 let toml = r#"
1036 [accounts.alice]
1037 format = "wav"
1038 "#;
1039 let cfg = Config::from_toml(toml).unwrap();
1040 let eff = cfg
1041 .resolve("alice", Some("nonexistent"), &no_env(), &no_flags())
1042 .unwrap();
1043 assert_eq!(eff.format, AudioFormat::Wav);
1044 }
1045
1046 #[test]
1047 fn global_env_overrides_file() {
1048 let toml = r#"
1049 [accounts.alice]
1050 format = "flac"
1051 "#;
1052 let cfg = Config::from_toml(toml).unwrap();
1053 let env: HashMap<String, String> =
1054 [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
1055 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1056 assert_eq!(eff.format, AudioFormat::Mp3);
1057 }
1058
1059 #[test]
1060 fn per_account_env_overrides_global_env() {
1061 let toml = "[accounts.alice]\n";
1062 let cfg = Config::from_toml(toml).unwrap();
1063 let env: HashMap<String, String> = [
1064 ("SUNO_FORMAT".into(), "mp3".into()),
1065 ("SUNO_ALICE_FORMAT".into(), "wav".into()),
1066 ]
1067 .into_iter()
1068 .collect();
1069 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1070 assert_eq!(eff.format, AudioFormat::Wav);
1071 }
1072
1073 #[test]
1074 fn per_account_env_label_uppersnakedcase() {
1075 let toml = "[accounts.my-lib]\n";
1076 let cfg = Config::from_toml(toml).unwrap();
1077 let env: HashMap<String, String> = [("SUNO_MY_LIB_FORMAT".into(), "wav".into())]
1078 .into_iter()
1079 .collect();
1080 let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
1081 assert_eq!(eff.format, AudioFormat::Wav);
1082 }
1083
1084 #[test]
1085 fn flag_overrides_env_and_file() {
1086 let toml = r#"
1087 [accounts.alice]
1088 format = "flac"
1089 "#;
1090 let cfg = Config::from_toml(toml).unwrap();
1091 let env: HashMap<String, String> =
1092 [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
1093 let flags = FlagOverrides {
1094 settings: Settings {
1095 format: Some(AudioFormat::Wav),
1096 ..Default::default()
1097 },
1098 ..Default::default()
1099 };
1100 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1101 assert_eq!(eff.format, AudioFormat::Wav);
1102 }
1103
1104 #[test]
1105 fn token_precedence() {
1106 let toml = r#"
1107 [accounts.alice]
1108 token = "file_tok"
1109 "#;
1110 let cfg = Config::from_toml(toml).unwrap();
1111
1112 let env: HashMap<String, String> = [("SUNO_TOKEN".into(), "env_tok".into())]
1114 .into_iter()
1115 .collect();
1116 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1117 assert_eq!(eff.token.as_deref(), Some("env_tok"));
1118 assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1119
1120 let flags = FlagOverrides {
1122 token: Some("flag_tok".into()),
1123 ..Default::default()
1124 };
1125 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1126 assert_eq!(eff.token.as_deref(), Some("flag_tok"));
1127 assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1128 }
1129
1130 #[test]
1131 fn stored_token_is_populated_from_config_when_no_override_exists() {
1132 let toml = r#"
1133 [accounts.alice]
1134 token = "file_tok"
1135 "#;
1136 let cfg = Config::from_toml(toml).unwrap();
1137 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1138 assert_eq!(eff.token, None);
1139 assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1140 assert_eq!(eff.token_command, None);
1141 }
1142
1143 #[test]
1144 fn per_account_token_env_overrides_global() {
1145 let toml = "[accounts.alice]\n";
1146 let cfg = Config::from_toml(toml).unwrap();
1147 let env: HashMap<String, String> = [
1148 ("SUNO_TOKEN".into(), "global".into()),
1149 ("SUNO_ALICE_TOKEN".into(), "per_account".into()),
1150 ]
1151 .into_iter()
1152 .collect();
1153 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1154 assert_eq!(eff.token.as_deref(), Some("per_account"));
1155 }
1156
1157 #[test]
1158 fn token_command_resolves_from_defaults_account_source_and_env() {
1159 let toml = r#"
1160 [defaults]
1161 token_command = "defaults"
1162
1163 [accounts.alice]
1164 token_command = "account"
1165
1166 [accounts.alice.sources.liked]
1167 token_command = "source"
1168 "#;
1169 let cfg = Config::from_toml(toml).unwrap();
1170
1171 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1172 assert_eq!(eff.token_command.as_deref(), Some("account"));
1173
1174 let eff = cfg
1175 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1176 .unwrap();
1177 assert_eq!(eff.token_command.as_deref(), Some("source"));
1178
1179 let env: HashMap<String, String> = [("SUNO_TOKEN_COMMAND".into(), "global".into())]
1180 .into_iter()
1181 .collect();
1182 let eff = cfg
1183 .resolve("alice", Some("liked"), &env, &no_flags())
1184 .unwrap();
1185 assert_eq!(eff.token_command.as_deref(), Some("global"));
1186
1187 let env: HashMap<String, String> = [
1188 ("SUNO_TOKEN_COMMAND".into(), "global".into()),
1189 ("SUNO_ALICE_TOKEN_COMMAND".into(), "per_account".into()),
1190 ]
1191 .into_iter()
1192 .collect();
1193 let eff = cfg
1194 .resolve("alice", Some("liked"), &env, &no_flags())
1195 .unwrap();
1196 assert_eq!(eff.token_command.as_deref(), Some("per_account"));
1197 }
1198
1199 #[test]
1200 fn per_account_token_command_env_label_uppersnakedcase() {
1201 let cfg = Config::from_toml("[accounts.my-lib]\n").unwrap();
1202 let env: HashMap<String, String> = [("SUNO_MY_LIB_TOKEN_COMMAND".into(), "command".into())]
1203 .into_iter()
1204 .collect();
1205 let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
1206 assert_eq!(eff.token_command.as_deref(), Some("command"));
1207 }
1208
1209 #[test]
1210 fn invalid_env_u32_errors() {
1211 let toml = "[accounts.alice]\n";
1212 let cfg = Config::from_toml(toml).unwrap();
1213 let env: HashMap<String, String> = [("SUNO_CONCURRENCY".into(), "many".into())]
1214 .into_iter()
1215 .collect();
1216 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1217 }
1218
1219 #[test]
1220 fn animated_covers_defaults_off_and_follows_precedence() {
1221 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1223 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1224 assert!(!eff.animated_covers);
1225
1226 let toml = r#"
1228 [defaults]
1229 animated_covers = true
1230
1231 [accounts.alice.sources.liked]
1232 animated_covers = false
1233 "#;
1234 let cfg = Config::from_toml(toml).unwrap();
1235
1236 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1238 assert!(eff.animated_covers);
1239
1240 let eff = cfg
1242 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1243 .unwrap();
1244 assert!(!eff.animated_covers);
1245
1246 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "true".into())]
1248 .into_iter()
1249 .collect();
1250 let eff = cfg
1251 .resolve("alice", Some("liked"), &env, &no_flags())
1252 .unwrap();
1253 assert!(eff.animated_covers);
1254
1255 let flags = FlagOverrides {
1257 settings: Settings {
1258 animated_covers: Some(false),
1259 ..Default::default()
1260 },
1261 ..Default::default()
1262 };
1263 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1264 assert!(!eff.animated_covers);
1265 }
1266
1267 #[test]
1268 fn video_mp4_defaults_off_and_follows_precedence() {
1269 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1271 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1272 assert!(!eff.video_mp4);
1273
1274 let toml = r#"
1276 [defaults]
1277 video_mp4 = true
1278
1279 [accounts.alice.sources.liked]
1280 video_mp4 = false
1281 "#;
1282 let cfg = Config::from_toml(toml).unwrap();
1283 assert!(
1284 cfg.resolve("alice", None, &no_env(), &no_flags())
1285 .unwrap()
1286 .video_mp4
1287 );
1288 assert!(
1289 !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1290 .unwrap()
1291 .video_mp4
1292 );
1293
1294 let env: HashMap<String, String> = [("SUNO_VIDEO_MP4".into(), "true".into())]
1295 .into_iter()
1296 .collect();
1297 assert!(
1298 cfg.resolve("alice", Some("liked"), &env, &no_flags())
1299 .unwrap()
1300 .video_mp4
1301 );
1302
1303 let flags = FlagOverrides {
1304 settings: Settings {
1305 video_mp4: Some(false),
1306 ..Default::default()
1307 },
1308 ..Default::default()
1309 };
1310 assert!(
1311 !cfg.resolve("alice", Some("liked"), &env, &flags)
1312 .unwrap()
1313 .video_mp4
1314 );
1315 }
1316
1317 #[test]
1318 fn download_stems_defaults_off_and_follows_precedence() {
1319 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1322 assert!(
1323 !cfg.resolve("alice", None, &no_env(), &no_flags())
1324 .unwrap()
1325 .download_stems
1326 );
1327
1328 let toml = r#"
1330 [defaults]
1331 download_stems = true
1332
1333 [accounts.alice.sources.liked]
1334 download_stems = false
1335 "#;
1336 let cfg = Config::from_toml(toml).unwrap();
1337 assert!(
1338 cfg.resolve("alice", None, &no_env(), &no_flags())
1339 .unwrap()
1340 .download_stems
1341 );
1342 assert!(
1343 !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1344 .unwrap()
1345 .download_stems
1346 );
1347
1348 let env: HashMap<String, String> = [("SUNO_DOWNLOAD_STEMS".into(), "true".into())]
1349 .into_iter()
1350 .collect();
1351 assert!(
1352 cfg.resolve("alice", Some("liked"), &env, &no_flags())
1353 .unwrap()
1354 .download_stems
1355 );
1356
1357 let flags = FlagOverrides {
1358 settings: Settings {
1359 download_stems: Some(false),
1360 ..Default::default()
1361 },
1362 ..Default::default()
1363 };
1364 assert!(
1365 !cfg.resolve("alice", Some("liked"), &env, &flags)
1366 .unwrap()
1367 .download_stems
1368 );
1369 }
1370
1371 #[test]
1372 fn stem_format_defaults_to_wav_and_follows_precedence() {
1373 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1375 assert_eq!(
1376 cfg.resolve("alice", None, &no_env(), &no_flags())
1377 .unwrap()
1378 .stem_format,
1379 StemFormat::Wav
1380 );
1381
1382 let toml = r#"
1384 [defaults]
1385 stem_format = "mp3"
1386
1387 [accounts.alice.sources.liked]
1388 stem_format = "wav"
1389 "#;
1390 let cfg = Config::from_toml(toml).unwrap();
1391 assert_eq!(
1392 cfg.resolve("alice", None, &no_env(), &no_flags())
1393 .unwrap()
1394 .stem_format,
1395 StemFormat::Mp3
1396 );
1397 assert_eq!(
1398 cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1399 .unwrap()
1400 .stem_format,
1401 StemFormat::Wav
1402 );
1403
1404 let env: HashMap<String, String> = [("SUNO_STEM_FORMAT".into(), "mp3".into())]
1405 .into_iter()
1406 .collect();
1407 assert_eq!(
1408 cfg.resolve("alice", Some("liked"), &env, &no_flags())
1409 .unwrap()
1410 .stem_format,
1411 StemFormat::Mp3
1412 );
1413
1414 let flags = FlagOverrides {
1415 settings: Settings {
1416 stem_format: Some(StemFormat::Wav),
1417 ..Default::default()
1418 },
1419 ..Default::default()
1420 };
1421 assert_eq!(
1422 cfg.resolve("alice", Some("liked"), &env, &flags)
1423 .unwrap()
1424 .stem_format,
1425 StemFormat::Wav
1426 );
1427 }
1428
1429 #[test]
1430 fn stem_format_rejects_flac_and_unknown() {
1431 assert!("flac".parse::<StemFormat>().is_err());
1434 assert!("aac".parse::<StemFormat>().is_err());
1435 assert_eq!("WAV".parse::<StemFormat>().unwrap(), StemFormat::Wav);
1436 assert_eq!("Mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
1437 assert!(Config::from_toml("[defaults]\nstem_format = \"flac\"\n").is_err());
1439 }
1440
1441 #[test]
1442 fn video_cover_retention_drives_cover_artifacts_not_the_music_video() {
1443 let resolve = |retention: &str| {
1444 let toml = format!("[accounts.alice]\nvideo_cover_retention = \"{retention}\"\n");
1445 Config::from_toml(&toml)
1446 .unwrap()
1447 .resolve("alice", None, &no_env(), &no_flags())
1448 .unwrap()
1449 };
1450
1451 let neither = resolve("neither");
1452 assert!(!neither.animated_covers && !neither.raw_animated_cover);
1453 assert_eq!(neither.video_cover_retention, VideoCoverRetention::Neither);
1454
1455 let webp = resolve("webp");
1456 assert!(webp.animated_covers && !webp.raw_animated_cover);
1457 assert_eq!(webp.video_cover_retention, VideoCoverRetention::Webp);
1458
1459 let mp4 = resolve("mp4");
1462 assert!(!mp4.animated_covers && mp4.raw_animated_cover);
1463 assert!(!mp4.video_mp4);
1464 assert_eq!(mp4.video_cover_retention, VideoCoverRetention::Mp4);
1465
1466 let both = resolve("both");
1467 assert!(both.animated_covers && both.raw_animated_cover);
1468 assert!(!both.video_mp4);
1469 assert_eq!(both.video_cover_retention, VideoCoverRetention::Both);
1470 }
1471
1472 #[test]
1473 fn video_mp4_is_independent_of_cover_retention() {
1474 let toml = "[accounts.alice]\nvideo_mp4 = true\nvideo_cover_retention = \"webp\"\n";
1477 let cfg = Config::from_toml(toml).unwrap();
1478 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1479 assert!(eff.video_mp4);
1480 assert!(eff.animated_covers);
1481 assert!(!eff.raw_animated_cover);
1482 assert_eq!(eff.video_cover_retention, VideoCoverRetention::Webp);
1483 }
1484
1485 #[test]
1486 fn animated_cover_webp_knobs_follow_precedence_and_validate_ranges() {
1487 let toml = r#"
1488 [defaults]
1489 animated_cover_quality = 80
1490 animated_cover_max_fps = 20
1491 animated_cover_max_width = 640
1492 animated_cover_compression_level = 3
1493
1494 [accounts.alice.sources.liked]
1495 animated_cover_quality = 75
1496 "#;
1497 let cfg = Config::from_toml(toml).unwrap();
1498 let eff = cfg
1499 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1500 .unwrap();
1501 assert_eq!(eff.animated_cover_webp.quality, 75);
1502 assert_eq!(eff.animated_cover_webp.max_fps, 20);
1503 assert_eq!(eff.animated_cover_webp.max_width, Some(640));
1504 assert_eq!(eff.animated_cover_webp.compression_level, 3);
1505
1506 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVER_QUALITY".into(), "90".into())]
1507 .into_iter()
1508 .collect();
1509 let eff = cfg
1510 .resolve("alice", Some("liked"), &env, &no_flags())
1511 .unwrap();
1512 assert_eq!(eff.animated_cover_webp.quality, 90);
1513
1514 let flags = FlagOverrides {
1515 settings: Settings {
1516 animated_cover_quality: Some(95),
1517 animated_cover_max_width: Some(512),
1518 animated_cover_compression_level: Some(4),
1519 ..Default::default()
1520 },
1521 ..Default::default()
1522 };
1523 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1524 assert_eq!(eff.animated_cover_webp.quality, 95);
1525 assert_eq!(eff.animated_cover_webp.max_width, Some(512));
1526 assert_eq!(eff.animated_cover_webp.compression_level, 4);
1527
1528 let bad_env: HashMap<String, String> =
1529 [("SUNO_ANIMATED_COVER_QUALITY".into(), "101".into())]
1530 .into_iter()
1531 .collect();
1532 assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1533 }
1534
1535 #[test]
1536 fn video_cover_retention_parses_formats_and_reports_kept_artifacts() {
1537 assert_eq!(
1539 "NEITHER".parse::<VideoCoverRetention>().unwrap(),
1540 VideoCoverRetention::Neither
1541 );
1542 assert_eq!(
1543 "WebP".parse::<VideoCoverRetention>().unwrap(),
1544 VideoCoverRetention::Webp
1545 );
1546 assert_eq!(
1547 "mp4".parse::<VideoCoverRetention>().unwrap(),
1548 VideoCoverRetention::Mp4
1549 );
1550 assert_eq!(
1551 "Both".parse::<VideoCoverRetention>().unwrap(),
1552 VideoCoverRetention::Both
1553 );
1554 assert!("mkv".parse::<VideoCoverRetention>().is_err());
1556
1557 for mode in [
1559 VideoCoverRetention::Neither,
1560 VideoCoverRetention::Webp,
1561 VideoCoverRetention::Mp4,
1562 VideoCoverRetention::Both,
1563 ] {
1564 assert_eq!(
1565 mode.to_string().parse::<VideoCoverRetention>().unwrap(),
1566 mode
1567 );
1568 }
1569
1570 assert!(!VideoCoverRetention::Neither.keeps_webp());
1572 assert!(!VideoCoverRetention::Neither.keeps_mp4());
1573 assert!(VideoCoverRetention::Webp.keeps_webp());
1574 assert!(!VideoCoverRetention::Webp.keeps_mp4());
1575 assert!(!VideoCoverRetention::Mp4.keeps_webp());
1576 assert!(VideoCoverRetention::Mp4.keeps_mp4());
1577 assert!(VideoCoverRetention::Both.keeps_webp());
1578 assert!(VideoCoverRetention::Both.keeps_mp4());
1579 }
1580
1581 #[test]
1582 fn video_cover_retention_resolves_from_env_and_rejects_unknown() {
1583 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1584
1585 let env: HashMap<String, String> = [("SUNO_VIDEO_COVER_RETENTION".into(), "both".into())]
1587 .into_iter()
1588 .collect();
1589 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1590 assert_eq!(eff.video_cover_retention, VideoCoverRetention::Both);
1591 assert!(eff.animated_covers);
1592 assert!(eff.raw_animated_cover);
1593
1594 let bad_env: HashMap<String, String> =
1596 [("SUNO_VIDEO_COVER_RETENTION".into(), "mkv".into())]
1597 .into_iter()
1598 .collect();
1599 assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1600 }
1601
1602 #[test]
1603 fn animated_cover_compression_level_enforces_zero_to_four() {
1604 let cfg = Config::from_toml(
1607 "[defaults]\nanimated_cover_compression_level = 4\n[accounts.alice]\n",
1608 )
1609 .unwrap();
1610 assert_eq!(
1611 cfg.resolve("alice", None, &no_env(), &no_flags())
1612 .unwrap()
1613 .animated_cover_webp
1614 .compression_level,
1615 4
1616 );
1617
1618 let cfg = Config::from_toml(
1620 "[defaults]\nanimated_cover_compression_level = 5\n[accounts.alice]\n",
1621 )
1622 .unwrap();
1623 assert!(cfg.resolve("alice", None, &no_env(), &no_flags()).is_err());
1624
1625 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1627 let bad_env: HashMap<String, String> =
1628 [("SUNO_ANIMATED_COVER_COMPRESSION_LEVEL".into(), "5".into())]
1629 .into_iter()
1630 .collect();
1631 assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1632
1633 let junk_env: HashMap<String, String> =
1635 [("SUNO_ANIMATED_COVER_MAX_FPS".into(), "abc".into())]
1636 .into_iter()
1637 .collect();
1638 assert!(cfg.resolve("alice", None, &junk_env, &no_flags()).is_err());
1639 }
1640
1641 #[test]
1642 fn animated_cover_lossless_defaults_off_and_follows_precedence() {
1643 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1646 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1647 assert_eq!(eff.animated_cover_webp.quality, 95);
1648 assert_eq!(eff.animated_cover_webp.compression_level, 4);
1649 assert!(!eff.animated_cover_webp.lossless);
1650
1651 let cfg =
1653 Config::from_toml("[defaults]\nanimated_cover_lossless = true\n[accounts.alice]\n")
1654 .unwrap();
1655 assert!(
1656 cfg.resolve("alice", None, &no_env(), &no_flags())
1657 .unwrap()
1658 .animated_cover_webp
1659 .lossless
1660 );
1661 let env: HashMap<String, String> =
1662 [("SUNO_ANIMATED_COVER_LOSSLESS".into(), "false".into())]
1663 .into_iter()
1664 .collect();
1665 assert!(
1666 !cfg.resolve("alice", None, &env, &no_flags())
1667 .unwrap()
1668 .animated_cover_webp
1669 .lossless
1670 );
1671 let flags = FlagOverrides {
1672 settings: Settings {
1673 animated_cover_lossless: Some(true),
1674 ..Default::default()
1675 },
1676 ..Default::default()
1677 };
1678 assert!(
1679 cfg.resolve("alice", None, &env, &flags)
1680 .unwrap()
1681 .animated_cover_webp
1682 .lossless
1683 );
1684
1685 let bad_env: HashMap<String, String> =
1687 [("SUNO_ANIMATED_COVER_LOSSLESS".into(), "maybe".into())]
1688 .into_iter()
1689 .collect();
1690 assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1691 }
1692
1693 #[test]
1694 fn animated_cover_max_width_defaults_to_native() {
1695 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1697 assert_eq!(
1698 cfg.resolve("alice", None, &no_env(), &no_flags())
1699 .unwrap()
1700 .animated_cover_webp
1701 .max_width,
1702 None
1703 );
1704 }
1705
1706 #[test]
1707 fn text_sidecars_default_off_and_follow_precedence() {
1708 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1710 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1711 assert!(!eff.details_sidecar);
1712 assert!(!eff.lyrics_sidecar);
1713
1714 let toml = r#"
1715 [defaults]
1716 details_sidecar = true
1717
1718 [accounts.alice.sources.liked]
1719 details_sidecar = false
1720 "#;
1721 let cfg = Config::from_toml(toml).unwrap();
1722
1723 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1725 assert!(eff.details_sidecar);
1726 assert!(!eff.lyrics_sidecar);
1727
1728 let eff = cfg
1730 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1731 .unwrap();
1732 assert!(!eff.details_sidecar);
1733
1734 let env: HashMap<String, String> = [
1736 ("SUNO_DETAILS_SIDECAR".into(), "true".into()),
1737 ("SUNO_LYRICS_SIDECAR".into(), "true".into()),
1738 ]
1739 .into_iter()
1740 .collect();
1741 let eff = cfg
1742 .resolve("alice", Some("liked"), &env, &no_flags())
1743 .unwrap();
1744 assert!(eff.details_sidecar);
1745 assert!(eff.lyrics_sidecar);
1746
1747 let flags = FlagOverrides {
1748 settings: Settings {
1749 lyrics_sidecar: Some(false),
1750 ..Default::default()
1751 },
1752 ..Default::default()
1753 };
1754 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1755 assert!(eff.details_sidecar);
1756 assert!(!eff.lyrics_sidecar);
1757 }
1758
1759 #[test]
1760 fn invalid_env_bool_errors() {
1761 let toml = "[accounts.alice]\n";
1762 let cfg = Config::from_toml(toml).unwrap();
1763 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "yes".into())]
1764 .into_iter()
1765 .collect();
1766 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1767 }
1768
1769 #[test]
1770 fn unknown_account_errors() {
1771 let cfg = Config::from_toml("").unwrap();
1772 assert!(cfg.resolve("nobody", None, &no_env(), &no_flags()).is_err());
1773 }
1774
1775 #[test]
1776 fn validation_nested_roots() {
1777 let toml = r#"
1778 [accounts.alice]
1779 root = "/music"
1780
1781 [accounts.bob]
1782 root = "/music/bob"
1783 "#;
1784 assert!(Config::from_toml(toml).is_err());
1785 }
1786
1787 #[test]
1788 fn validation_non_nested_roots_ok() {
1789 let toml = r#"
1790 [accounts.alice]
1791 root = "/music/alice"
1792
1793 [accounts.bob]
1794 root = "/music/bob"
1795 "#;
1796 assert!(Config::from_toml(toml).is_ok());
1797 }
1798
1799 #[test]
1800 fn invalid_toml_errors() {
1801 assert!(Config::from_toml("not valid toml ][").is_err());
1802 }
1803
1804 #[test]
1805 fn duplicate_account_label_errors() {
1806 let toml = "
1808 [accounts.alice]
1809 token = \"tok1\"
1810
1811 [accounts.alice]
1812 token = \"tok2\"
1813 ";
1814 assert!(Config::from_toml(toml).is_err());
1815 }
1816
1817 #[test]
1818 fn parse_error_does_not_echo_token() {
1819 let toml = "[accounts.alice]\ntoken = \"unterminated\n";
1821 let err = Config::from_toml(toml).unwrap_err().to_string();
1822 assert!(!err.contains("unterminated"), "error leaked token: {err}");
1823 }
1824
1825 #[test]
1826 fn validation_env_prefix_collision_errors() {
1827 let toml = "
1829 [accounts.my-lib]
1830 [accounts.my_lib]
1831 ";
1832 assert!(Config::from_toml(toml).is_err());
1833 }
1834
1835 #[test]
1836 fn audio_format_display_roundtrip() {
1837 for fmt in [
1838 AudioFormat::Mp3,
1839 AudioFormat::Flac,
1840 AudioFormat::Wav,
1841 AudioFormat::Alac,
1842 ] {
1843 let s = fmt.to_string();
1844 assert_eq!(s.parse::<AudioFormat>().unwrap(), fmt);
1845 }
1846 }
1847
1848 #[test]
1849 fn audio_format_ext() {
1850 assert_eq!(AudioFormat::Mp3.ext(), "mp3");
1851 assert_eq!(AudioFormat::Flac.ext(), "flac");
1852 assert_eq!(AudioFormat::Wav.ext(), "wav");
1853 assert_eq!(AudioFormat::Alac.ext(), "m4a");
1854 }
1855
1856 #[test]
1857 fn naming_template_follows_precedence() {
1858 let toml = r#"
1859 [defaults]
1860 naming_template = "{title}"
1861
1862 [accounts.alice]
1863 naming_template = "{creator}/{title}"
1864
1865 [accounts.alice.sources.liked]
1866 naming_template = "{handle}/{title} [{id8}]"
1867 "#;
1868 let cfg = Config::from_toml(toml).unwrap();
1869
1870 let eff = cfg
1872 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1873 .unwrap();
1874 assert_eq!(eff.naming_template, "{handle}/{title} [{id8}]");
1875
1876 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1878 assert_eq!(eff.naming_template, "{creator}/{title}");
1879
1880 let env: HashMap<String, String> = [("SUNO_NAMING_TEMPLATE".into(), "{id}".into())]
1882 .into_iter()
1883 .collect();
1884 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1885 assert_eq!(eff.naming_template, "{id}");
1886
1887 let flags = FlagOverrides {
1889 settings: Settings {
1890 naming_template: Some("{title}/{id8}".into()),
1891 ..Default::default()
1892 },
1893 ..Default::default()
1894 };
1895 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1896 assert_eq!(eff.naming_template, "{title}/{id8}");
1897 }
1898
1899 #[test]
1900 fn min_newest_follows_precedence() {
1901 let toml = r#"
1905 [defaults]
1906 min_newest = 5
1907
1908 [accounts.alice]
1909 min_newest = 7
1910
1911 [accounts.alice.sources.liked]
1912 min_newest = 9
1913 "#;
1914 let cfg = Config::from_toml(toml).unwrap();
1915
1916 let bare = Config::from_toml("[accounts.bob]\n").unwrap();
1918 assert_eq!(
1919 bare.resolve("bob", None, &no_env(), &no_flags())
1920 .unwrap()
1921 .min_newest,
1922 1
1923 );
1924
1925 let eff = cfg
1927 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1928 .unwrap();
1929 assert_eq!(eff.min_newest, 9);
1930
1931 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1933 assert_eq!(eff.min_newest, 7);
1934
1935 let env: HashMap<String, String> = [("SUNO_MIN_NEWEST".into(), "11".into())]
1937 .into_iter()
1938 .collect();
1939 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1940 assert_eq!(eff.min_newest, 11);
1941
1942 let env: HashMap<String, String> = [
1944 ("SUNO_MIN_NEWEST".into(), "11".into()),
1945 ("SUNO_ALICE_MIN_NEWEST".into(), "13".into()),
1946 ]
1947 .into_iter()
1948 .collect();
1949 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1950 assert_eq!(eff.min_newest, 13);
1951
1952 let flags = FlagOverrides {
1954 settings: Settings {
1955 min_newest: Some(15),
1956 ..Default::default()
1957 },
1958 ..Default::default()
1959 };
1960 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1961 assert_eq!(eff.min_newest, 15);
1962
1963 let bad_env: HashMap<String, String> = [("SUNO_MIN_NEWEST".into(), "notnum".into())]
1965 .into_iter()
1966 .collect();
1967 assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1968 }
1969
1970 #[test]
1971 fn character_set_follows_precedence() {
1972 let toml = r#"
1973 [defaults]
1974 character_set = "ascii"
1975
1976 [accounts.alice]
1977 "#;
1978 let cfg = Config::from_toml(toml).unwrap();
1979
1980 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1982 assert_eq!(eff.character_set, CharacterSet::Ascii);
1983
1984 let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "unicode".into())]
1986 .into_iter()
1987 .collect();
1988 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1989 assert_eq!(eff.character_set, CharacterSet::Unicode);
1990
1991 let flags = FlagOverrides {
1993 settings: Settings {
1994 character_set: Some(CharacterSet::Ascii),
1995 ..Default::default()
1996 },
1997 ..Default::default()
1998 };
1999 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
2000 assert_eq!(eff.character_set, CharacterSet::Ascii);
2001 }
2002
2003 #[test]
2004 fn invalid_character_set_env_errors() {
2005 let toml = "[accounts.alice]\n";
2006 let cfg = Config::from_toml(toml).unwrap();
2007 let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "utf8".into())]
2008 .into_iter()
2009 .collect();
2010 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
2011 }
2012
2013 #[test]
2014 fn areas_parse_full_table() {
2015 let toml = r#"
2016 [accounts.alice]
2017 token = "t"
2018 [accounts.alice.areas]
2019 library = "off"
2020 liked = "copy"
2021 playlists = "mirror"
2022 [accounts.alice.areas.playlist]
2023 "pl_abc123" = "mirror"
2024 "pl_def456" = "copy"
2025 "#;
2026 let cfg = Config::from_toml(toml).unwrap();
2027 let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
2028 assert_eq!(areas.library, Some(AreaMode::Off));
2029 assert_eq!(areas.liked, Some(SourceMode::Copy));
2030 assert_eq!(areas.playlists, Some(SourceMode::Mirror));
2031 assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
2032 assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
2033 }
2034
2035 #[test]
2036 fn album_overrides_parse_and_resolve() {
2037 let toml = r#"
2038 [accounts.alice]
2039 token = "t"
2040 [accounts.alice.albums]
2041 "root_abc123" = "Preferred Name"
2042 "root_def456" = "Another Album"
2043 "root_blank" = " "
2044 "#;
2045 let cfg = Config::from_toml(toml).unwrap();
2046 assert_eq!(
2047 cfg.accounts["alice"].albums["root_abc123"],
2048 "Preferred Name"
2049 );
2050 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
2051 assert_eq!(eff.album_overrides["root_abc123"], "Preferred Name");
2052 assert_eq!(eff.album_overrides["root_def456"], "Another Album");
2053 assert!(!eff.album_overrides.contains_key("root_blank"));
2055 }
2056
2057 #[test]
2058 fn album_overrides_absent_by_default() {
2059 let cfg = Config::from_toml("[accounts.alice]\ntoken = \"t\"\n").unwrap();
2060 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
2061 assert!(eff.album_overrides.is_empty());
2062 }
2063
2064 #[test]
2065 fn areas_library_accepts_copy_and_mirror() {
2066 for (raw, expect) in [
2067 ("copy", AreaMode::Mode(SourceMode::Copy)),
2068 ("mirror", AreaMode::Mode(SourceMode::Mirror)),
2069 ] {
2070 let toml =
2071 format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
2072 let cfg = Config::from_toml(&toml).unwrap();
2073 assert_eq!(
2074 cfg.accounts["a"].areas.as_ref().unwrap().library,
2075 Some(expect)
2076 );
2077 }
2078 }
2079
2080 #[test]
2081 fn areas_bad_mode_errors() {
2082 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
2083 assert!(Config::from_toml(toml).is_err());
2084 }
2085
2086 #[test]
2087 fn areas_bad_playlist_mode_errors() {
2088 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
2089 assert!(Config::from_toml(toml).is_err());
2091 }
2092
2093 #[test]
2094 fn areas_unknown_field_errors() {
2095 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
2097 assert!(Config::from_toml(toml).is_err());
2098 }
2099
2100 #[test]
2101 fn areas_absent_is_none() {
2102 let toml = "[accounts.a]\ntoken = \"t\"\n";
2103 assert!(
2104 Config::from_toml(toml).unwrap().accounts["a"]
2105 .areas
2106 .is_none()
2107 );
2108 }
2109
2110 fn base_settings(format: AudioFormat) -> EffectiveSettings {
2111 let toml = "[accounts.a]\n";
2112 let cfg = Config::from_toml(toml).unwrap();
2113 let mut eff = cfg.resolve("a", None, &no_env(), &no_flags()).unwrap();
2114 eff.format = format;
2115 eff
2116 }
2117
2118 #[test]
2119 fn requires_ffmpeg_flac_always_needs_it() {
2120 let mut eff = base_settings(AudioFormat::Flac);
2121 eff.animated_covers = false;
2122 assert!(eff.requires_ffmpeg());
2123 eff.animated_covers = true;
2124 assert!(eff.requires_ffmpeg());
2125 }
2126
2127 #[test]
2128 fn requires_ffmpeg_alac_always_needs_it() {
2129 let mut eff = base_settings(AudioFormat::Alac);
2130 eff.animated_covers = false;
2131 assert!(eff.requires_ffmpeg(), "alac transcodes, so needs ffmpeg");
2132 }
2133
2134 #[test]
2135 fn requires_ffmpeg_mp3_needs_it_only_for_animated_webp() {
2136 let mut eff = base_settings(AudioFormat::Mp3);
2137 assert!(!eff.requires_ffmpeg(), "mp3 + no covers = no ffmpeg");
2138 eff.animated_covers = true;
2139 assert!(eff.requires_ffmpeg(), "mp3 + animated webp = needs ffmpeg");
2140 eff.raw_animated_cover = true;
2143 assert!(
2144 eff.requires_ffmpeg(),
2145 "mp3 + both (webp + raw mp4) = needs ffmpeg"
2146 );
2147 eff.animated_covers = false;
2148 assert!(!eff.requires_ffmpeg(), "mp3 + raw mp4 only = no ffmpeg");
2149 }
2150}