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`](crate::config::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`](crate::config::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`](crate::config::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`](crate::config::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_truth_table() {
129 // ffmpeg is needed for lossless output (FLAC/ALAC transcode from WAV) or
130 // an animated WebP cover (MP4->WebP). A raw MP4 cover alone does not need
131 // it, so raw_animated_cover never changes the answer.
132 for (label, format, animated, raw, want) in [
133 ("flac, no covers", AudioFormat::Flac, false, false, true),
134 ("flac, animated", AudioFormat::Flac, true, false, true),
135 ("alac, no covers", AudioFormat::Alac, false, false, true),
136 ("mp3, no covers", AudioFormat::Mp3, false, false, false),
137 ("mp3, animated webp", AudioFormat::Mp3, true, false, true),
138 (
139 "mp3, both webp and raw mp4",
140 AudioFormat::Mp3,
141 true,
142 true,
143 true,
144 ),
145 ("mp3, raw mp4 only", AudioFormat::Mp3, false, true, false),
146 ] {
147 let mut eff = base_settings(format);
148 eff.animated_covers = animated;
149 eff.raw_animated_cover = raw;
150 assert_eq!(eff.requires_ffmpeg(), want, "{label}");
151 }
152 }
153
154 #[test]
155 fn animated_covers_flag_override_truth_table() {
156 // `--animated-covers` maps to Some(true) and is reported as overridden
157 // only when the resolved effective value is false (a neither/mp4
158 // retention dropped it). An absent flag is never an override.
159 for (label, flag, effective, want) in [
160 ("neither/mp4 drops the flag", Some(true), false, true),
161 ("webp/both honours the flag", Some(true), true, false),
162 ("absent flag, effective false", None, false, false),
163 ("absent flag, effective true", None, true, false),
164 ] {
165 assert_eq!(
166 animated_covers_flag_overridden(flag, effective),
167 want,
168 "{label}"
169 );
170 }
171 }
172}