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