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_u32(
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_u32(
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_u32(
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_bool(
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_bool(
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_bool(
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_bool(
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_bool(
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_bool(
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_u32(
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_u32(
645 flag: Option<u32>,
646 env_str: Option<&str>,
647 src: Option<u32>,
648 acc: Option<u32>,
649 defaults: Option<u32>,
650 compiled: u32,
651 name: &str,
652) -> Result<u32> {
653 if let Some(v) = flag {
654 return Ok(v);
655 }
656 if let Some(s) = env_str {
657 return s
658 .parse()
659 .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
660 }
661 Ok(src.or(acc).or(defaults).unwrap_or(compiled))
662}
663
664fn resolve_bool(
665 flag: Option<bool>,
666 env_str: Option<&str>,
667 src: Option<bool>,
668 acc: Option<bool>,
669 defaults: Option<bool>,
670 compiled: bool,
671 name: &str,
672) -> Result<bool> {
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
807#[cfg(test)]
808mod tests {
809 use super::*;
810
811 fn no_env() -> HashMap<String, String> {
812 HashMap::new()
813 }
814
815 fn no_flags() -> FlagOverrides {
816 FlagOverrides::default()
817 }
818
819 #[test]
820 fn parse_empty_toml() {
821 let cfg = Config::from_toml("").unwrap();
822 assert!(cfg.accounts.is_empty());
823 }
824
825 #[test]
826 fn parse_basic_account() {
827 let toml = r#"
828 [accounts.alice]
829 token = "tok"
830 root = "/music"
831 "#;
832 let cfg = Config::from_toml(toml).unwrap();
833 let acc = &cfg.accounts["alice"];
834 assert_eq!(acc.token.as_deref(), Some("tok"));
835 assert_eq!(acc.root.as_deref(), Some("/music"));
836 }
837
838 #[test]
839 fn account_id_parses_and_resolves() {
840 let toml = r#"
841 [accounts.alice]
842 token = "tok"
843 root = "/music"
844 account_id = "user_abc123"
845 "#;
846 let cfg = Config::from_toml(toml).unwrap();
847 assert_eq!(
848 cfg.accounts["alice"].account_id.as_deref(),
849 Some("user_abc123")
850 );
851 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
852 assert_eq!(eff.account_id.as_deref(), Some("user_abc123"));
853 }
854
855 #[test]
856 fn parse_defaults_section() {
857 let toml = r#"
858 [defaults]
859 format = "mp3"
860 concurrency = 8
861 retries = 5
862 min_newest = 2
863 animated_covers = true
864 video_cover_retention = "both"
865 animated_cover_quality = 85
866 animated_cover_max_fps = 18
867 animated_cover_max_width = 720
868 animated_cover_compression_level = 4
869 "#;
870 let cfg = Config::from_toml(toml).unwrap();
871 assert_eq!(cfg.defaults.format, Some(AudioFormat::Mp3));
872 assert_eq!(cfg.defaults.concurrency, Some(8));
873 assert_eq!(cfg.defaults.retries, Some(5));
874 assert_eq!(cfg.defaults.min_newest, Some(2));
875 assert_eq!(cfg.defaults.animated_covers, Some(true));
876 assert_eq!(
877 cfg.defaults.video_cover_retention,
878 Some(VideoCoverRetention::Both)
879 );
880 assert_eq!(cfg.defaults.animated_cover_quality, Some(85));
881 assert_eq!(cfg.defaults.animated_cover_max_fps, Some(18));
882 assert_eq!(cfg.defaults.animated_cover_max_width, Some(720));
883 assert_eq!(cfg.defaults.animated_cover_compression_level, Some(4));
884 }
885
886 #[test]
887 fn compiled_defaults_when_nothing_set() {
888 let toml = "[accounts.alice]\n";
889 let cfg = Config::from_toml(toml).unwrap();
890 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
891 assert_eq!(
892 eff,
893 EffectiveSettings {
894 token: None,
895 stored_token: None,
896 token_command: None,
897 account_id: None,
898 format: AudioFormat::Flac,
899 concurrency: 4,
900 retries: 3,
901 min_newest: 1,
902 animated_covers: false,
903 raw_animated_cover: false,
904 video_cover_retention: VideoCoverRetention::Neither,
905 animated_cover_webp: WebpEncodeSettings::default(),
906 details_sidecar: false,
907 lyrics_sidecar: false,
908 lrc_sidecar: false,
909 video_mp4: false,
910 download_stems: false,
911 stem_format: StemFormat::Wav,
912 naming_template: crate::naming::DEFAULT_TEMPLATE.to_owned(),
913 character_set: CharacterSet::Unicode,
914 areas: None,
915 album_overrides: BTreeMap::new(),
916 }
917 );
918 }
919
920 #[test]
921 fn file_defaults_override_compiled() {
922 let toml = r#"
923 [defaults]
924 format = "mp3"
925 concurrency = 8
926
927 [accounts.alice]
928 "#;
929 let cfg = Config::from_toml(toml).unwrap();
930 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
931 assert_eq!(eff.format, AudioFormat::Mp3);
932 assert_eq!(eff.concurrency, 8);
933 assert_eq!(eff.retries, 3); }
935
936 #[test]
937 fn account_settings_override_defaults() {
938 let toml = r#"
939 [defaults]
940 format = "mp3"
941
942 [accounts.alice]
943 format = "wav"
944 "#;
945 let cfg = Config::from_toml(toml).unwrap();
946 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
947 assert_eq!(eff.format, AudioFormat::Wav);
948 }
949
950 #[test]
951 fn per_source_overrides_account() {
952 let toml = r#"
953 [accounts.alice]
954 format = "flac"
955
956 [accounts.alice.sources.liked]
957 format = "mp3"
958 "#;
959 let cfg = Config::from_toml(toml).unwrap();
960 let eff = cfg
961 .resolve("alice", Some("liked"), &no_env(), &no_flags())
962 .unwrap();
963 assert_eq!(eff.format, AudioFormat::Mp3);
964 }
965
966 #[test]
967 fn unknown_source_falls_back_to_account() {
968 let toml = r#"
969 [accounts.alice]
970 format = "wav"
971 "#;
972 let cfg = Config::from_toml(toml).unwrap();
973 let eff = cfg
974 .resolve("alice", Some("nonexistent"), &no_env(), &no_flags())
975 .unwrap();
976 assert_eq!(eff.format, AudioFormat::Wav);
977 }
978
979 #[test]
980 fn global_env_overrides_file() {
981 let toml = r#"
982 [accounts.alice]
983 format = "flac"
984 "#;
985 let cfg = Config::from_toml(toml).unwrap();
986 let env: HashMap<String, String> =
987 [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
988 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
989 assert_eq!(eff.format, AudioFormat::Mp3);
990 }
991
992 #[test]
993 fn per_account_env_overrides_global_env() {
994 let toml = "[accounts.alice]\n";
995 let cfg = Config::from_toml(toml).unwrap();
996 let env: HashMap<String, String> = [
997 ("SUNO_FORMAT".into(), "mp3".into()),
998 ("SUNO_ALICE_FORMAT".into(), "wav".into()),
999 ]
1000 .into_iter()
1001 .collect();
1002 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1003 assert_eq!(eff.format, AudioFormat::Wav);
1004 }
1005
1006 #[test]
1007 fn per_account_env_label_uppersnakedcase() {
1008 let toml = "[accounts.my-lib]\n";
1009 let cfg = Config::from_toml(toml).unwrap();
1010 let env: HashMap<String, String> = [("SUNO_MY_LIB_FORMAT".into(), "wav".into())]
1011 .into_iter()
1012 .collect();
1013 let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
1014 assert_eq!(eff.format, AudioFormat::Wav);
1015 }
1016
1017 #[test]
1018 fn flag_overrides_env_and_file() {
1019 let toml = r#"
1020 [accounts.alice]
1021 format = "flac"
1022 "#;
1023 let cfg = Config::from_toml(toml).unwrap();
1024 let env: HashMap<String, String> =
1025 [("SUNO_FORMAT".into(), "mp3".into())].into_iter().collect();
1026 let flags = FlagOverrides {
1027 format: Some(AudioFormat::Wav),
1028 ..Default::default()
1029 };
1030 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1031 assert_eq!(eff.format, AudioFormat::Wav);
1032 }
1033
1034 #[test]
1035 fn token_precedence() {
1036 let toml = r#"
1037 [accounts.alice]
1038 token = "file_tok"
1039 "#;
1040 let cfg = Config::from_toml(toml).unwrap();
1041
1042 let env: HashMap<String, String> = [("SUNO_TOKEN".into(), "env_tok".into())]
1044 .into_iter()
1045 .collect();
1046 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1047 assert_eq!(eff.token.as_deref(), Some("env_tok"));
1048 assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1049
1050 let flags = FlagOverrides {
1052 token: Some("flag_tok".into()),
1053 ..Default::default()
1054 };
1055 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1056 assert_eq!(eff.token.as_deref(), Some("flag_tok"));
1057 assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1058 }
1059
1060 #[test]
1061 fn stored_token_is_populated_from_config_when_no_override_exists() {
1062 let toml = r#"
1063 [accounts.alice]
1064 token = "file_tok"
1065 "#;
1066 let cfg = Config::from_toml(toml).unwrap();
1067 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1068 assert_eq!(eff.token, None);
1069 assert_eq!(eff.stored_token.as_deref(), Some("file_tok"));
1070 assert_eq!(eff.token_command, None);
1071 }
1072
1073 #[test]
1074 fn per_account_token_env_overrides_global() {
1075 let toml = "[accounts.alice]\n";
1076 let cfg = Config::from_toml(toml).unwrap();
1077 let env: HashMap<String, String> = [
1078 ("SUNO_TOKEN".into(), "global".into()),
1079 ("SUNO_ALICE_TOKEN".into(), "per_account".into()),
1080 ]
1081 .into_iter()
1082 .collect();
1083 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1084 assert_eq!(eff.token.as_deref(), Some("per_account"));
1085 }
1086
1087 #[test]
1088 fn token_command_resolves_from_defaults_account_source_and_env() {
1089 let toml = r#"
1090 [defaults]
1091 token_command = "defaults"
1092
1093 [accounts.alice]
1094 token_command = "account"
1095
1096 [accounts.alice.sources.liked]
1097 token_command = "source"
1098 "#;
1099 let cfg = Config::from_toml(toml).unwrap();
1100
1101 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1102 assert_eq!(eff.token_command.as_deref(), Some("account"));
1103
1104 let eff = cfg
1105 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1106 .unwrap();
1107 assert_eq!(eff.token_command.as_deref(), Some("source"));
1108
1109 let env: HashMap<String, String> = [("SUNO_TOKEN_COMMAND".into(), "global".into())]
1110 .into_iter()
1111 .collect();
1112 let eff = cfg
1113 .resolve("alice", Some("liked"), &env, &no_flags())
1114 .unwrap();
1115 assert_eq!(eff.token_command.as_deref(), Some("global"));
1116
1117 let env: HashMap<String, String> = [
1118 ("SUNO_TOKEN_COMMAND".into(), "global".into()),
1119 ("SUNO_ALICE_TOKEN_COMMAND".into(), "per_account".into()),
1120 ]
1121 .into_iter()
1122 .collect();
1123 let eff = cfg
1124 .resolve("alice", Some("liked"), &env, &no_flags())
1125 .unwrap();
1126 assert_eq!(eff.token_command.as_deref(), Some("per_account"));
1127 }
1128
1129 #[test]
1130 fn per_account_token_command_env_label_uppersnakedcase() {
1131 let cfg = Config::from_toml("[accounts.my-lib]\n").unwrap();
1132 let env: HashMap<String, String> = [("SUNO_MY_LIB_TOKEN_COMMAND".into(), "command".into())]
1133 .into_iter()
1134 .collect();
1135 let eff = cfg.resolve("my-lib", None, &env, &no_flags()).unwrap();
1136 assert_eq!(eff.token_command.as_deref(), Some("command"));
1137 }
1138
1139 #[test]
1140 fn invalid_env_u32_errors() {
1141 let toml = "[accounts.alice]\n";
1142 let cfg = Config::from_toml(toml).unwrap();
1143 let env: HashMap<String, String> = [("SUNO_CONCURRENCY".into(), "many".into())]
1144 .into_iter()
1145 .collect();
1146 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1147 }
1148
1149 #[test]
1150 fn animated_covers_defaults_off_and_follows_precedence() {
1151 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1153 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1154 assert!(!eff.animated_covers);
1155
1156 let toml = r#"
1158 [defaults]
1159 animated_covers = true
1160
1161 [accounts.alice.sources.liked]
1162 animated_covers = false
1163 "#;
1164 let cfg = Config::from_toml(toml).unwrap();
1165
1166 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1168 assert!(eff.animated_covers);
1169
1170 let eff = cfg
1172 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1173 .unwrap();
1174 assert!(!eff.animated_covers);
1175
1176 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "true".into())]
1178 .into_iter()
1179 .collect();
1180 let eff = cfg
1181 .resolve("alice", Some("liked"), &env, &no_flags())
1182 .unwrap();
1183 assert!(eff.animated_covers);
1184
1185 let flags = FlagOverrides {
1187 animated_covers: Some(false),
1188 ..Default::default()
1189 };
1190 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1191 assert!(!eff.animated_covers);
1192 }
1193
1194 #[test]
1195 fn video_mp4_defaults_off_and_follows_precedence() {
1196 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1198 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1199 assert!(!eff.video_mp4);
1200
1201 let toml = r#"
1203 [defaults]
1204 video_mp4 = true
1205
1206 [accounts.alice.sources.liked]
1207 video_mp4 = false
1208 "#;
1209 let cfg = Config::from_toml(toml).unwrap();
1210 assert!(
1211 cfg.resolve("alice", None, &no_env(), &no_flags())
1212 .unwrap()
1213 .video_mp4
1214 );
1215 assert!(
1216 !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1217 .unwrap()
1218 .video_mp4
1219 );
1220
1221 let env: HashMap<String, String> = [("SUNO_VIDEO_MP4".into(), "true".into())]
1222 .into_iter()
1223 .collect();
1224 assert!(
1225 cfg.resolve("alice", Some("liked"), &env, &no_flags())
1226 .unwrap()
1227 .video_mp4
1228 );
1229
1230 let flags = FlagOverrides {
1231 video_mp4: Some(false),
1232 ..Default::default()
1233 };
1234 assert!(
1235 !cfg.resolve("alice", Some("liked"), &env, &flags)
1236 .unwrap()
1237 .video_mp4
1238 );
1239 }
1240
1241 #[test]
1242 fn download_stems_defaults_off_and_follows_precedence() {
1243 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1246 assert!(
1247 !cfg.resolve("alice", None, &no_env(), &no_flags())
1248 .unwrap()
1249 .download_stems
1250 );
1251
1252 let toml = r#"
1254 [defaults]
1255 download_stems = true
1256
1257 [accounts.alice.sources.liked]
1258 download_stems = false
1259 "#;
1260 let cfg = Config::from_toml(toml).unwrap();
1261 assert!(
1262 cfg.resolve("alice", None, &no_env(), &no_flags())
1263 .unwrap()
1264 .download_stems
1265 );
1266 assert!(
1267 !cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1268 .unwrap()
1269 .download_stems
1270 );
1271
1272 let env: HashMap<String, String> = [("SUNO_DOWNLOAD_STEMS".into(), "true".into())]
1273 .into_iter()
1274 .collect();
1275 assert!(
1276 cfg.resolve("alice", Some("liked"), &env, &no_flags())
1277 .unwrap()
1278 .download_stems
1279 );
1280
1281 let flags = FlagOverrides {
1282 download_stems: Some(false),
1283 ..Default::default()
1284 };
1285 assert!(
1286 !cfg.resolve("alice", Some("liked"), &env, &flags)
1287 .unwrap()
1288 .download_stems
1289 );
1290 }
1291
1292 #[test]
1293 fn stem_format_defaults_to_wav_and_follows_precedence() {
1294 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1296 assert_eq!(
1297 cfg.resolve("alice", None, &no_env(), &no_flags())
1298 .unwrap()
1299 .stem_format,
1300 StemFormat::Wav
1301 );
1302
1303 let toml = r#"
1305 [defaults]
1306 stem_format = "mp3"
1307
1308 [accounts.alice.sources.liked]
1309 stem_format = "wav"
1310 "#;
1311 let cfg = Config::from_toml(toml).unwrap();
1312 assert_eq!(
1313 cfg.resolve("alice", None, &no_env(), &no_flags())
1314 .unwrap()
1315 .stem_format,
1316 StemFormat::Mp3
1317 );
1318 assert_eq!(
1319 cfg.resolve("alice", Some("liked"), &no_env(), &no_flags())
1320 .unwrap()
1321 .stem_format,
1322 StemFormat::Wav
1323 );
1324
1325 let env: HashMap<String, String> = [("SUNO_STEM_FORMAT".into(), "mp3".into())]
1326 .into_iter()
1327 .collect();
1328 assert_eq!(
1329 cfg.resolve("alice", Some("liked"), &env, &no_flags())
1330 .unwrap()
1331 .stem_format,
1332 StemFormat::Mp3
1333 );
1334
1335 let flags = FlagOverrides {
1336 stem_format: Some(StemFormat::Wav),
1337 ..Default::default()
1338 };
1339 assert_eq!(
1340 cfg.resolve("alice", Some("liked"), &env, &flags)
1341 .unwrap()
1342 .stem_format,
1343 StemFormat::Wav
1344 );
1345 }
1346
1347 #[test]
1348 fn stem_format_rejects_flac_and_unknown() {
1349 assert!("flac".parse::<StemFormat>().is_err());
1352 assert!("aac".parse::<StemFormat>().is_err());
1353 assert_eq!("WAV".parse::<StemFormat>().unwrap(), StemFormat::Wav);
1354 assert_eq!("Mp3".parse::<StemFormat>().unwrap(), StemFormat::Mp3);
1355 assert!(Config::from_toml("[defaults]\nstem_format = \"flac\"\n").is_err());
1357 }
1358
1359 #[test]
1360 fn video_cover_retention_drives_cover_artifacts_not_the_music_video() {
1361 let resolve = |retention: &str| {
1362 let toml = format!("[accounts.alice]\nvideo_cover_retention = \"{retention}\"\n");
1363 Config::from_toml(&toml)
1364 .unwrap()
1365 .resolve("alice", None, &no_env(), &no_flags())
1366 .unwrap()
1367 };
1368
1369 let neither = resolve("neither");
1370 assert!(!neither.animated_covers && !neither.raw_animated_cover);
1371 assert_eq!(neither.video_cover_retention, VideoCoverRetention::Neither);
1372
1373 let webp = resolve("webp");
1374 assert!(webp.animated_covers && !webp.raw_animated_cover);
1375 assert_eq!(webp.video_cover_retention, VideoCoverRetention::Webp);
1376
1377 let mp4 = resolve("mp4");
1380 assert!(!mp4.animated_covers && mp4.raw_animated_cover);
1381 assert!(!mp4.video_mp4);
1382 assert_eq!(mp4.video_cover_retention, VideoCoverRetention::Mp4);
1383
1384 let both = resolve("both");
1385 assert!(both.animated_covers && both.raw_animated_cover);
1386 assert!(!both.video_mp4);
1387 assert_eq!(both.video_cover_retention, VideoCoverRetention::Both);
1388 }
1389
1390 #[test]
1391 fn video_mp4_is_independent_of_cover_retention() {
1392 let toml = "[accounts.alice]\nvideo_mp4 = true\nvideo_cover_retention = \"webp\"\n";
1395 let cfg = Config::from_toml(toml).unwrap();
1396 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1397 assert!(eff.video_mp4);
1398 assert!(eff.animated_covers);
1399 assert!(!eff.raw_animated_cover);
1400 assert_eq!(eff.video_cover_retention, VideoCoverRetention::Webp);
1401 }
1402
1403 #[test]
1404 fn animated_cover_webp_knobs_follow_precedence_and_validate_ranges() {
1405 let toml = r#"
1406 [defaults]
1407 animated_cover_quality = 80
1408 animated_cover_max_fps = 20
1409 animated_cover_max_width = 640
1410 animated_cover_compression_level = 3
1411
1412 [accounts.alice.sources.liked]
1413 animated_cover_quality = 75
1414 "#;
1415 let cfg = Config::from_toml(toml).unwrap();
1416 let eff = cfg
1417 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1418 .unwrap();
1419 assert_eq!(eff.animated_cover_webp.quality, 75);
1420 assert_eq!(eff.animated_cover_webp.max_fps, 20);
1421 assert_eq!(eff.animated_cover_webp.max_width, Some(640));
1422 assert_eq!(eff.animated_cover_webp.compression_level, 3);
1423
1424 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVER_QUALITY".into(), "90".into())]
1425 .into_iter()
1426 .collect();
1427 let eff = cfg
1428 .resolve("alice", Some("liked"), &env, &no_flags())
1429 .unwrap();
1430 assert_eq!(eff.animated_cover_webp.quality, 90);
1431
1432 let flags = FlagOverrides {
1433 animated_cover_quality: Some(95),
1434 animated_cover_max_width: Some(512),
1435 animated_cover_compression_level: Some(6),
1436 ..Default::default()
1437 };
1438 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1439 assert_eq!(eff.animated_cover_webp.quality, 95);
1440 assert_eq!(eff.animated_cover_webp.max_width, Some(512));
1441 assert_eq!(eff.animated_cover_webp.compression_level, 6);
1442
1443 let bad_env: HashMap<String, String> =
1444 [("SUNO_ANIMATED_COVER_QUALITY".into(), "101".into())]
1445 .into_iter()
1446 .collect();
1447 assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1448 }
1449
1450 #[test]
1451 fn video_cover_retention_parses_formats_and_reports_kept_artifacts() {
1452 assert_eq!(
1454 "NEITHER".parse::<VideoCoverRetention>().unwrap(),
1455 VideoCoverRetention::Neither
1456 );
1457 assert_eq!(
1458 "WebP".parse::<VideoCoverRetention>().unwrap(),
1459 VideoCoverRetention::Webp
1460 );
1461 assert_eq!(
1462 "mp4".parse::<VideoCoverRetention>().unwrap(),
1463 VideoCoverRetention::Mp4
1464 );
1465 assert_eq!(
1466 "Both".parse::<VideoCoverRetention>().unwrap(),
1467 VideoCoverRetention::Both
1468 );
1469 assert!("mkv".parse::<VideoCoverRetention>().is_err());
1471
1472 for mode in [
1474 VideoCoverRetention::Neither,
1475 VideoCoverRetention::Webp,
1476 VideoCoverRetention::Mp4,
1477 VideoCoverRetention::Both,
1478 ] {
1479 assert_eq!(
1480 mode.to_string().parse::<VideoCoverRetention>().unwrap(),
1481 mode
1482 );
1483 }
1484
1485 assert!(!VideoCoverRetention::Neither.keeps_webp());
1487 assert!(!VideoCoverRetention::Neither.keeps_mp4());
1488 assert!(VideoCoverRetention::Webp.keeps_webp());
1489 assert!(!VideoCoverRetention::Webp.keeps_mp4());
1490 assert!(!VideoCoverRetention::Mp4.keeps_webp());
1491 assert!(VideoCoverRetention::Mp4.keeps_mp4());
1492 assert!(VideoCoverRetention::Both.keeps_webp());
1493 assert!(VideoCoverRetention::Both.keeps_mp4());
1494 }
1495
1496 #[test]
1497 fn video_cover_retention_resolves_from_env_and_rejects_unknown() {
1498 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1499
1500 let env: HashMap<String, String> = [("SUNO_VIDEO_COVER_RETENTION".into(), "both".into())]
1502 .into_iter()
1503 .collect();
1504 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1505 assert_eq!(eff.video_cover_retention, VideoCoverRetention::Both);
1506 assert!(eff.animated_covers);
1507 assert!(eff.raw_animated_cover);
1508
1509 let bad_env: HashMap<String, String> =
1511 [("SUNO_VIDEO_COVER_RETENTION".into(), "mkv".into())]
1512 .into_iter()
1513 .collect();
1514 assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1515 }
1516
1517 #[test]
1518 fn animated_cover_compression_level_enforces_zero_to_six() {
1519 let cfg = Config::from_toml(
1521 "[defaults]\nanimated_cover_compression_level = 6\n[accounts.alice]\n",
1522 )
1523 .unwrap();
1524 assert_eq!(
1525 cfg.resolve("alice", None, &no_env(), &no_flags())
1526 .unwrap()
1527 .animated_cover_webp
1528 .compression_level,
1529 6
1530 );
1531
1532 let cfg = Config::from_toml(
1534 "[defaults]\nanimated_cover_compression_level = 7\n[accounts.alice]\n",
1535 )
1536 .unwrap();
1537 assert!(cfg.resolve("alice", None, &no_env(), &no_flags()).is_err());
1538
1539 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1541 let bad_env: HashMap<String, String> =
1542 [("SUNO_ANIMATED_COVER_COMPRESSION_LEVEL".into(), "7".into())]
1543 .into_iter()
1544 .collect();
1545 assert!(cfg.resolve("alice", None, &bad_env, &no_flags()).is_err());
1546
1547 let junk_env: HashMap<String, String> =
1549 [("SUNO_ANIMATED_COVER_MAX_FPS".into(), "abc".into())]
1550 .into_iter()
1551 .collect();
1552 assert!(cfg.resolve("alice", None, &junk_env, &no_flags()).is_err());
1553 }
1554
1555 #[test]
1556 fn animated_cover_max_width_defaults_to_native() {
1557 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1559 assert_eq!(
1560 cfg.resolve("alice", None, &no_env(), &no_flags())
1561 .unwrap()
1562 .animated_cover_webp
1563 .max_width,
1564 None
1565 );
1566 }
1567
1568 #[test]
1569 fn text_sidecars_default_off_and_follow_precedence() {
1570 let cfg = Config::from_toml("[accounts.alice]\n").unwrap();
1572 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1573 assert!(!eff.details_sidecar);
1574 assert!(!eff.lyrics_sidecar);
1575
1576 let toml = r#"
1577 [defaults]
1578 details_sidecar = true
1579
1580 [accounts.alice.sources.liked]
1581 details_sidecar = false
1582 "#;
1583 let cfg = Config::from_toml(toml).unwrap();
1584
1585 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1587 assert!(eff.details_sidecar);
1588 assert!(!eff.lyrics_sidecar);
1589
1590 let eff = cfg
1592 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1593 .unwrap();
1594 assert!(!eff.details_sidecar);
1595
1596 let env: HashMap<String, String> = [
1598 ("SUNO_DETAILS_SIDECAR".into(), "true".into()),
1599 ("SUNO_LYRICS_SIDECAR".into(), "true".into()),
1600 ]
1601 .into_iter()
1602 .collect();
1603 let eff = cfg
1604 .resolve("alice", Some("liked"), &env, &no_flags())
1605 .unwrap();
1606 assert!(eff.details_sidecar);
1607 assert!(eff.lyrics_sidecar);
1608
1609 let flags = FlagOverrides {
1610 lyrics_sidecar: Some(false),
1611 ..Default::default()
1612 };
1613 let eff = cfg.resolve("alice", Some("liked"), &env, &flags).unwrap();
1614 assert!(eff.details_sidecar);
1615 assert!(!eff.lyrics_sidecar);
1616 }
1617
1618 #[test]
1619 fn invalid_env_bool_errors() {
1620 let toml = "[accounts.alice]\n";
1621 let cfg = Config::from_toml(toml).unwrap();
1622 let env: HashMap<String, String> = [("SUNO_ANIMATED_COVERS".into(), "yes".into())]
1623 .into_iter()
1624 .collect();
1625 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1626 }
1627
1628 #[test]
1629 fn unknown_account_errors() {
1630 let cfg = Config::from_toml("").unwrap();
1631 assert!(cfg.resolve("nobody", None, &no_env(), &no_flags()).is_err());
1632 }
1633
1634 #[test]
1635 fn validation_nested_roots() {
1636 let toml = r#"
1637 [accounts.alice]
1638 root = "/music"
1639
1640 [accounts.bob]
1641 root = "/music/bob"
1642 "#;
1643 assert!(Config::from_toml(toml).is_err());
1644 }
1645
1646 #[test]
1647 fn validation_non_nested_roots_ok() {
1648 let toml = r#"
1649 [accounts.alice]
1650 root = "/music/alice"
1651
1652 [accounts.bob]
1653 root = "/music/bob"
1654 "#;
1655 assert!(Config::from_toml(toml).is_ok());
1656 }
1657
1658 #[test]
1659 fn invalid_toml_errors() {
1660 assert!(Config::from_toml("not valid toml ][").is_err());
1661 }
1662
1663 #[test]
1664 fn duplicate_account_label_errors() {
1665 let toml = "
1667 [accounts.alice]
1668 token = \"tok1\"
1669
1670 [accounts.alice]
1671 token = \"tok2\"
1672 ";
1673 assert!(Config::from_toml(toml).is_err());
1674 }
1675
1676 #[test]
1677 fn parse_error_does_not_echo_token() {
1678 let toml = "[accounts.alice]\ntoken = \"unterminated\n";
1680 let err = Config::from_toml(toml).unwrap_err().to_string();
1681 assert!(!err.contains("unterminated"), "error leaked token: {err}");
1682 }
1683
1684 #[test]
1685 fn validation_env_prefix_collision_errors() {
1686 let toml = "
1688 [accounts.my-lib]
1689 [accounts.my_lib]
1690 ";
1691 assert!(Config::from_toml(toml).is_err());
1692 }
1693
1694 #[test]
1695 fn audio_format_display_roundtrip() {
1696 for fmt in [AudioFormat::Mp3, AudioFormat::Flac, AudioFormat::Wav] {
1697 let s = fmt.to_string();
1698 assert_eq!(s.parse::<AudioFormat>().unwrap(), fmt);
1699 }
1700 }
1701
1702 #[test]
1703 fn naming_template_follows_precedence() {
1704 let toml = r#"
1705 [defaults]
1706 naming_template = "{title}"
1707
1708 [accounts.alice]
1709 naming_template = "{creator}/{title}"
1710
1711 [accounts.alice.sources.liked]
1712 naming_template = "{handle}/{title} [{id8}]"
1713 "#;
1714 let cfg = Config::from_toml(toml).unwrap();
1715
1716 let eff = cfg
1718 .resolve("alice", Some("liked"), &no_env(), &no_flags())
1719 .unwrap();
1720 assert_eq!(eff.naming_template, "{handle}/{title} [{id8}]");
1721
1722 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1724 assert_eq!(eff.naming_template, "{creator}/{title}");
1725
1726 let env: HashMap<String, String> = [("SUNO_NAMING_TEMPLATE".into(), "{id}".into())]
1728 .into_iter()
1729 .collect();
1730 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1731 assert_eq!(eff.naming_template, "{id}");
1732
1733 let flags = FlagOverrides {
1735 naming_template: Some("{title}/{id8}".into()),
1736 ..Default::default()
1737 };
1738 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1739 assert_eq!(eff.naming_template, "{title}/{id8}");
1740 }
1741
1742 #[test]
1743 fn character_set_follows_precedence() {
1744 let toml = r#"
1745 [defaults]
1746 character_set = "ascii"
1747
1748 [accounts.alice]
1749 "#;
1750 let cfg = Config::from_toml(toml).unwrap();
1751
1752 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1754 assert_eq!(eff.character_set, CharacterSet::Ascii);
1755
1756 let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "unicode".into())]
1758 .into_iter()
1759 .collect();
1760 let eff = cfg.resolve("alice", None, &env, &no_flags()).unwrap();
1761 assert_eq!(eff.character_set, CharacterSet::Unicode);
1762
1763 let flags = FlagOverrides {
1765 character_set: Some(CharacterSet::Ascii),
1766 ..Default::default()
1767 };
1768 let eff = cfg.resolve("alice", None, &env, &flags).unwrap();
1769 assert_eq!(eff.character_set, CharacterSet::Ascii);
1770 }
1771
1772 #[test]
1773 fn invalid_character_set_env_errors() {
1774 let toml = "[accounts.alice]\n";
1775 let cfg = Config::from_toml(toml).unwrap();
1776 let env: HashMap<String, String> = [("SUNO_CHARACTER_SET".into(), "utf8".into())]
1777 .into_iter()
1778 .collect();
1779 assert!(cfg.resolve("alice", None, &env, &no_flags()).is_err());
1780 }
1781
1782 #[test]
1783 fn areas_parse_full_table() {
1784 let toml = r#"
1785 [accounts.alice]
1786 token = "t"
1787 [accounts.alice.areas]
1788 library = "off"
1789 liked = "copy"
1790 playlists = "mirror"
1791 [accounts.alice.areas.playlist]
1792 "pl_abc123" = "mirror"
1793 "pl_def456" = "copy"
1794 "#;
1795 let cfg = Config::from_toml(toml).unwrap();
1796 let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
1797 assert_eq!(areas.library, Some(AreaMode::Off));
1798 assert_eq!(areas.liked, Some(SourceMode::Copy));
1799 assert_eq!(areas.playlists, Some(SourceMode::Mirror));
1800 assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
1801 assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
1802 }
1803
1804 #[test]
1805 fn album_overrides_parse_and_resolve() {
1806 let toml = r#"
1807 [accounts.alice]
1808 token = "t"
1809 [accounts.alice.albums]
1810 "root_abc123" = "Preferred Name"
1811 "root_def456" = "Another Album"
1812 "root_blank" = " "
1813 "#;
1814 let cfg = Config::from_toml(toml).unwrap();
1815 assert_eq!(
1816 cfg.accounts["alice"].albums["root_abc123"],
1817 "Preferred Name"
1818 );
1819 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1820 assert_eq!(eff.album_overrides["root_abc123"], "Preferred Name");
1821 assert_eq!(eff.album_overrides["root_def456"], "Another Album");
1822 assert!(!eff.album_overrides.contains_key("root_blank"));
1824 }
1825
1826 #[test]
1827 fn album_overrides_absent_by_default() {
1828 let cfg = Config::from_toml("[accounts.alice]\ntoken = \"t\"\n").unwrap();
1829 let eff = cfg.resolve("alice", None, &no_env(), &no_flags()).unwrap();
1830 assert!(eff.album_overrides.is_empty());
1831 }
1832
1833 #[test]
1834 fn areas_library_accepts_copy_and_mirror() {
1835 for (raw, expect) in [
1836 ("copy", AreaMode::Mode(SourceMode::Copy)),
1837 ("mirror", AreaMode::Mode(SourceMode::Mirror)),
1838 ] {
1839 let toml =
1840 format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
1841 let cfg = Config::from_toml(&toml).unwrap();
1842 assert_eq!(
1843 cfg.accounts["a"].areas.as_ref().unwrap().library,
1844 Some(expect)
1845 );
1846 }
1847 }
1848
1849 #[test]
1850 fn areas_bad_mode_errors() {
1851 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
1852 assert!(Config::from_toml(toml).is_err());
1853 }
1854
1855 #[test]
1856 fn areas_bad_playlist_mode_errors() {
1857 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
1858 assert!(Config::from_toml(toml).is_err());
1860 }
1861
1862 #[test]
1863 fn areas_unknown_field_errors() {
1864 let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
1866 assert!(Config::from_toml(toml).is_err());
1867 }
1868
1869 #[test]
1870 fn areas_absent_is_none() {
1871 let toml = "[accounts.a]\ntoken = \"t\"\n";
1872 assert!(
1873 Config::from_toml(toml).unwrap().accounts["a"]
1874 .areas
1875 .is_none()
1876 );
1877 }
1878}