Skip to main content

suno_core/config/
effective.rs

1//! The resolved output types produced by [`Config::resolve`].
2
3use std::collections::BTreeMap;
4
5use crate::naming::CharacterSet;
6use crate::vocab::{AudioFormat, StemFormat, VideoCoverRetention, WebpEncodeSettings};
7
8use super::shape::{AreasConfig, Settings};
9
10/// CLI flag overrides passed to [`Config::resolve`]. `None` means the flag
11/// was not provided.
12///
13/// Only `token` is carried top-level (the one field with a global `--token`
14/// flag); the rest nest in [`Settings`]. There is no `--token-command` flag, so
15/// `settings.token_command` is never populated from the CLI.
16#[derive(Debug, Default)]
17pub struct FlagOverrides {
18    pub token: Option<String>,
19    pub settings: Settings,
20}
21
22/// Resolved effective settings for one account/source combination.
23#[derive(Debug, Clone, PartialEq)]
24pub struct EffectiveSettings {
25    /// A direct token from `--token` or `SUNO_*_TOKEN`.
26    pub token: Option<String>,
27    /// A stored token from `[accounts.<label>].token`.
28    pub stored_token: Option<String>,
29    /// A command to run for the token when no direct token was supplied.
30    pub token_command: Option<String>,
31    /// The optional configured account id assertion (see [`AccountConfig`]).
32    pub account_id: Option<String>,
33    pub format: AudioFormat,
34    pub concurrency: u32,
35    pub retries: u32,
36    pub min_newest: u32,
37    pub animated_covers: bool,
38    /// Keep the raw album `cover.mp4` (`video_cover_url` verbatim, no transcode).
39    /// Driven by [`VideoCoverRetention::keeps_mp4`]; independent of `video_mp4`.
40    pub raw_animated_cover: bool,
41    pub video_cover_retention: VideoCoverRetention,
42    pub animated_cover_webp: WebpEncodeSettings,
43    pub details_sidecar: bool,
44    pub lyrics_sidecar: bool,
45    pub lrc_sidecar: bool,
46    pub video_mp4: bool,
47    pub download_stems: bool,
48    pub stem_format: StemFormat,
49    pub naming_template: String,
50    pub character_set: CharacterSet,
51    /// The per-account `[areas]` selection table, if configured.
52    pub areas: Option<AreasConfig>,
53    /// Manual album-name overrides, keyed by lineage root id, resolved from the
54    /// account's `[accounts.<label>.albums]` table. Deterministically ordered
55    /// (a [`BTreeMap`]) and pre-trimmed of empty values by [`Config::resolve`].
56    pub album_overrides: BTreeMap<String, String>,
57    /// Lead-track flags from `[accounts.<label>].lead_tracks`: clip ids (or
58    /// unique prefixes) each promoted to track 1 of their lineage album.
59    /// Trimmed, de-duplicated, and deterministically ordered by
60    /// [`Config::resolve`].
61    pub lead_tracks: Vec<String>,
62    /// Whether a lone-track album is numbered (defaults to `true`). `false`
63    /// leaves singletons unnumbered.
64    pub number_singletons: bool,
65}
66
67impl EffectiveSettings {
68    /// Returns `true` when these settings require ffmpeg to be on `PATH`.
69    ///
70    /// Lossless output (FLAC or ALAC) transcodes from the WAV render and an
71    /// animated WebP cover transcodes MP4→WebP, so either needs ffmpeg. A raw
72    /// MP4 alone, or MP3/WAV with no animated covers, does not.
73    pub fn requires_ffmpeg(&self) -> bool {
74        matches!(self.format, AudioFormat::Flac | AudioFormat::Alac) || self.animated_covers
75    }
76}
77
78/// Whether an explicit `--animated-covers` request was silently overridden by a
79/// `video_cover_retention` that keeps no animated WebP cover.
80///
81/// `--animated-covers` maps to `flag == Some(true)` (it is never `Some(false)`);
82/// when `video_cover_retention` is unset the flag value survives resolution, so a
83/// resolved `effective_animated == false` alongside `Some(true)` can only mean a
84/// `neither`/`mp4` retention dropped it. Pure, so the rule is unit-tested beside
85/// the resolver rather than in the CLI; the CLI only decides whether to print the
86/// note. The documented precedence is unchanged: this reports the override, it
87/// does not reverse it.
88pub fn animated_covers_flag_overridden(flag: Option<bool>, effective_animated: bool) -> bool {
89    flag == Some(true) && !effective_animated
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::config::Config;
96    use crate::config::fixtures::{no_env, no_flags};
97
98    #[test]
99    fn audio_format_display_roundtrip() {
100        for fmt in [
101            AudioFormat::Mp3,
102            AudioFormat::Flac,
103            AudioFormat::Wav,
104            AudioFormat::Alac,
105        ] {
106            let s = fmt.to_string();
107            assert_eq!(s.parse::<AudioFormat>().unwrap(), fmt);
108        }
109    }
110
111    #[test]
112    fn audio_format_ext() {
113        assert_eq!(AudioFormat::Mp3.ext(), "mp3");
114        assert_eq!(AudioFormat::Flac.ext(), "flac");
115        assert_eq!(AudioFormat::Wav.ext(), "wav");
116        assert_eq!(AudioFormat::Alac.ext(), "m4a");
117    }
118
119    fn base_settings(format: AudioFormat) -> EffectiveSettings {
120        let toml = "[accounts.a]\n";
121        let cfg = Config::from_toml(toml).unwrap();
122        let mut eff = cfg.resolve("a", None, &no_env(), &no_flags()).unwrap();
123        eff.format = format;
124        eff
125    }
126
127    #[test]
128    fn requires_ffmpeg_flac_always_needs_it() {
129        let mut eff = base_settings(AudioFormat::Flac);
130        eff.animated_covers = false;
131        assert!(eff.requires_ffmpeg());
132        eff.animated_covers = true;
133        assert!(eff.requires_ffmpeg());
134    }
135
136    #[test]
137    fn requires_ffmpeg_alac_always_needs_it() {
138        let mut eff = base_settings(AudioFormat::Alac);
139        eff.animated_covers = false;
140        assert!(eff.requires_ffmpeg(), "alac transcodes, so needs ffmpeg");
141    }
142
143    #[test]
144    fn requires_ffmpeg_mp3_needs_it_only_for_animated_webp() {
145        let mut eff = base_settings(AudioFormat::Mp3);
146        assert!(!eff.requires_ffmpeg(), "mp3 + no covers = no ffmpeg");
147        eff.animated_covers = true;
148        assert!(eff.requires_ffmpeg(), "mp3 + animated webp = needs ffmpeg");
149        eff.raw_animated_cover = true;
150        assert!(
151            eff.requires_ffmpeg(),
152            "mp3 + both (webp + raw mp4) = needs ffmpeg"
153        );
154        eff.animated_covers = false;
155        assert!(!eff.requires_ffmpeg(), "mp3 + raw mp4 only = no ffmpeg");
156    }
157
158    #[test]
159    fn flag_override_detected_for_neither_and_mp4() {
160        // `--animated-covers` was passed (`Some(true)`) but a `neither`/`mp4`
161        // retention keeps no animated WebP, so the resolver dropped it to
162        // `false`: the flag was silently overridden.
163        assert!(animated_covers_flag_overridden(Some(true), false));
164    }
165
166    #[test]
167    fn no_override_for_webp_or_both() {
168        // `webp`/`both` keep the animated cover, so the resolved value is `true`
169        // and the flag was honoured: no note.
170        assert!(!animated_covers_flag_overridden(Some(true), true));
171    }
172
173    #[test]
174    fn no_override_when_flag_absent() {
175        // No `--animated-covers`: never a flag override, whatever the retention
176        // resolved the effective value to.
177        assert!(!animated_covers_flag_overridden(None, false));
178        assert!(!animated_covers_flag_overridden(None, true));
179    }
180}