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