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