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