Skip to main content

suno_core/config/
resolve.rs

1//! Precedence resolution: layer the tiers (flag > per-account env > global
2//! env > per-source file > per-account file > global defaults > compiled)
3//! into [`EffectiveSettings`].
4
5use std::collections::{BTreeSet, HashMap};
6use std::str::FromStr;
7
8use crate::error::{Error, Result};
9use crate::naming::CharacterSet;
10use crate::vocab::{AudioFormat, VideoCoverRetention, WebpEncodeSettings};
11
12use super::effective::{EffectiveSettings, FlagOverrides};
13use super::label_to_env;
14use super::shape::Config;
15
16impl Config {
17    /// Compute effective settings for `account`, optionally scoped to `source`.
18    ///
19    /// The caller supplies the full environment map and any CLI flag overrides.
20    /// Precedence per field: flag > per-account env > global env > per-source
21    /// file > per-account file > global file defaults > compiled default.
22    pub fn resolve(
23        &self,
24        account: &str,
25        source: Option<&str>,
26        env: &HashMap<String, String>,
27        flags: &FlagOverrides,
28    ) -> Result<EffectiveSettings> {
29        let acc = self
30            .accounts
31            .get(account)
32            .ok_or_else(|| Error::Config(format!("account '{account}' not found")))?;
33
34        let src = source.and_then(|s| acc.sources.get(s));
35        let label_env = label_to_env(account);
36
37        // Look up per-account env first, falling back to global.
38        let env_val = |suffix: &str| -> Option<&str> {
39            env.get(&format!("SUNO_{label_env}_{suffix}"))
40                .or_else(|| env.get(&format!("SUNO_{suffix}")))
41                .map(String::as_str)
42        };
43
44        let format = resolve_enum(
45            flags.settings.format,
46            env_val("FORMAT"),
47            src.and_then(|s| s.settings.format),
48            acc.settings.format,
49            self.defaults.settings.format,
50            None,
51            "FORMAT",
52        )?
53        .unwrap_or(AudioFormat::Flac);
54
55        let concurrency = resolve_parsed(
56            flags.settings.concurrency,
57            env_val("CONCURRENCY"),
58            src.and_then(|s| s.settings.concurrency),
59            acc.settings.concurrency,
60            self.defaults.settings.concurrency,
61            4,
62            "CONCURRENCY",
63        )?;
64
65        let retries = resolve_parsed(
66            flags.settings.retries,
67            env_val("RETRIES"),
68            src.and_then(|s| s.settings.retries),
69            acc.settings.retries,
70            self.defaults.settings.retries,
71            3,
72            "RETRIES",
73        )?;
74
75        let min_newest = resolve_parsed(
76            flags.settings.min_newest,
77            env_val("MIN_NEWEST"),
78            src.and_then(|s| s.settings.min_newest),
79            acc.settings.min_newest,
80            self.defaults.settings.min_newest,
81            1,
82            "MIN_NEWEST",
83        )?;
84
85        let animated_covers = resolve_parsed(
86            flags.settings.animated_covers,
87            env_val("ANIMATED_COVERS"),
88            src.and_then(|s| s.settings.animated_covers),
89            acc.settings.animated_covers,
90            self.defaults.settings.animated_covers,
91            false,
92            "ANIMATED_COVERS",
93        )?;
94
95        let details_sidecar = resolve_parsed(
96            flags.settings.details_sidecar,
97            env_val("DETAILS_SIDECAR"),
98            src.and_then(|s| s.settings.details_sidecar),
99            acc.settings.details_sidecar,
100            self.defaults.settings.details_sidecar,
101            false,
102            "DETAILS_SIDECAR",
103        )?;
104
105        let lyrics_sidecar = resolve_parsed(
106            flags.settings.lyrics_sidecar,
107            env_val("LYRICS_SIDECAR"),
108            src.and_then(|s| s.settings.lyrics_sidecar),
109            acc.settings.lyrics_sidecar,
110            self.defaults.settings.lyrics_sidecar,
111            false,
112            "LYRICS_SIDECAR",
113        )?;
114
115        let lrc_sidecar = resolve_parsed(
116            flags.settings.lrc_sidecar,
117            env_val("LRC_SIDECAR"),
118            src.and_then(|s| s.settings.lrc_sidecar),
119            acc.settings.lrc_sidecar,
120            self.defaults.settings.lrc_sidecar,
121            false,
122            "LRC_SIDECAR",
123        )?;
124
125        let video_mp4 = resolve_parsed(
126            flags.settings.video_mp4,
127            env_val("VIDEO_MP4"),
128            src.and_then(|s| s.settings.video_mp4),
129            acc.settings.video_mp4,
130            self.defaults.settings.video_mp4,
131            false,
132            "VIDEO_MP4",
133        )?;
134
135        let download_stems = resolve_parsed(
136            flags.settings.download_stems,
137            env_val("DOWNLOAD_STEMS"),
138            src.and_then(|s| s.settings.download_stems),
139            acc.settings.download_stems,
140            self.defaults.settings.download_stems,
141            false,
142            "DOWNLOAD_STEMS",
143        )?;
144
145        let stem_format = resolve_enum(
146            flags.settings.stem_format,
147            env_val("STEM_FORMAT"),
148            src.and_then(|s| s.settings.stem_format),
149            acc.settings.stem_format,
150            self.defaults.settings.stem_format,
151            None,
152            "STEM_FORMAT",
153        )?
154        .unwrap_or_default();
155
156        let video_cover_retention = resolve_enum(
157            flags.settings.video_cover_retention,
158            env_val("VIDEO_COVER_RETENTION"),
159            src.and_then(|s| s.settings.video_cover_retention),
160            acc.settings.video_cover_retention,
161            self.defaults.settings.video_cover_retention,
162            None,
163            "VIDEO_COVER_RETENTION",
164        )?;
165        // `video_cover_retention` is the unified control for the album
166        // video-cover artifacts: `webp`/`both` keep the transcoded `cover.webp`,
167        // `mp4`/`both` the raw `cover.mp4`. The standalone music video
168        // (`video_url`) keeps its own `video_mp4` toggle, untouched here.
169        let (animated_covers, raw_animated_cover) = match video_cover_retention {
170            Some(retention) => (retention.keeps_webp(), retention.keeps_mp4()),
171            None => (animated_covers, false),
172        };
173
174        let defaults_webp = WebpEncodeSettings::default();
175        let animated_cover_quality = resolve_u8_ranged(
176            flags.settings.animated_cover_quality,
177            env_val("ANIMATED_COVER_QUALITY"),
178            src.and_then(|s| s.settings.animated_cover_quality),
179            acc.settings.animated_cover_quality,
180            self.defaults.settings.animated_cover_quality,
181            defaults_webp.quality,
182            "ANIMATED_COVER_QUALITY",
183            0..=100,
184        )?;
185        let animated_cover_max_fps = resolve_parsed(
186            flags.settings.animated_cover_max_fps,
187            env_val("ANIMATED_COVER_MAX_FPS"),
188            src.and_then(|s| s.settings.animated_cover_max_fps),
189            acc.settings.animated_cover_max_fps,
190            self.defaults.settings.animated_cover_max_fps,
191            defaults_webp.max_fps,
192            "ANIMATED_COVER_MAX_FPS",
193        )?;
194        let animated_cover_max_width = resolve_parsed_opt(
195            flags.settings.animated_cover_max_width,
196            env_val("ANIMATED_COVER_MAX_WIDTH"),
197            src.and_then(|s| s.settings.animated_cover_max_width),
198            acc.settings.animated_cover_max_width,
199            self.defaults.settings.animated_cover_max_width,
200            defaults_webp.max_width,
201            "ANIMATED_COVER_MAX_WIDTH",
202        )?;
203        let animated_cover_compression_level = resolve_u8_ranged(
204            flags.settings.animated_cover_compression_level,
205            env_val("ANIMATED_COVER_COMPRESSION_LEVEL"),
206            src.and_then(|s| s.settings.animated_cover_compression_level),
207            acc.settings.animated_cover_compression_level,
208            self.defaults.settings.animated_cover_compression_level,
209            defaults_webp.compression_level,
210            "ANIMATED_COVER_COMPRESSION_LEVEL",
211            0..=4,
212        )?;
213        let animated_cover_lossless = resolve_parsed(
214            flags.settings.animated_cover_lossless,
215            env_val("ANIMATED_COVER_LOSSLESS"),
216            src.and_then(|s| s.settings.animated_cover_lossless),
217            acc.settings.animated_cover_lossless,
218            self.defaults.settings.animated_cover_lossless,
219            defaults_webp.lossless,
220            "ANIMATED_COVER_LOSSLESS",
221        )?;
222
223        let naming_template = resolve_owned(
224            flags.settings.naming_template.clone(),
225            env_val("NAMING_TEMPLATE"),
226            src.and_then(|s| s.settings.naming_template.clone()),
227            acc.settings.naming_template.clone(),
228            self.defaults.settings.naming_template.clone(),
229        )
230        .unwrap_or_else(|| crate::naming::DEFAULT_TEMPLATE.to_owned());
231
232        let character_set = resolve_enum(
233            flags.settings.character_set,
234            env_val("CHARACTER_SET"),
235            src.and_then(|s| s.settings.character_set),
236            acc.settings.character_set,
237            self.defaults.settings.character_set,
238            None,
239            "CHARACTER_SET",
240        )?
241        .unwrap_or(CharacterSet::Unicode);
242
243        let number_singletons = resolve_parsed(
244            flags.settings.number_singletons,
245            env_val("NUMBER_SINGLETONS"),
246            src.and_then(|s| s.settings.number_singletons),
247            acc.settings.number_singletons,
248            self.defaults.settings.number_singletons,
249            true,
250            "NUMBER_SINGLETONS",
251        )?;
252
253        let token = flags
254            .token
255            .clone()
256            .or_else(|| env.get(&format!("SUNO_{label_env}_TOKEN")).cloned())
257            .or_else(|| env.get("SUNO_TOKEN").cloned());
258
259        let token_command = resolve_owned(
260            None,
261            env_val("TOKEN_COMMAND"),
262            src.and_then(|s| s.settings.token_command.clone()),
263            acc.settings.token_command.clone(),
264            self.defaults.settings.token_command.clone(),
265        );
266
267        Ok(EffectiveSettings {
268            token,
269            stored_token: acc.token.clone(),
270            token_command,
271            account_id: acc.account_id.clone(),
272            format,
273            concurrency,
274            retries,
275            min_newest,
276            animated_covers,
277            raw_animated_cover,
278            video_cover_retention: match (animated_covers, raw_animated_cover) {
279                (false, false) => VideoCoverRetention::Neither,
280                (true, false) => VideoCoverRetention::Webp,
281                (false, true) => VideoCoverRetention::Mp4,
282                (true, true) => VideoCoverRetention::Both,
283            },
284            animated_cover_webp: WebpEncodeSettings {
285                quality: animated_cover_quality,
286                max_fps: animated_cover_max_fps,
287                max_width: animated_cover_max_width,
288                lossless: animated_cover_lossless,
289                compression_level: animated_cover_compression_level,
290            },
291            details_sidecar,
292            lyrics_sidecar,
293            lrc_sidecar,
294            video_mp4,
295            download_stems,
296            stem_format,
297            naming_template,
298            character_set,
299            areas: acc.areas.clone(),
300            album_overrides: acc
301                .albums
302                .iter()
303                .filter(|(_, name)| !name.trim().is_empty())
304                .map(|(root_id, name)| (root_id.clone(), name.trim().to_owned()))
305                .collect(),
306            lead_tracks: acc
307                .lead_tracks
308                .iter()
309                .map(|entry| entry.trim())
310                .filter(|entry| !entry.is_empty())
311                .map(str::to_owned)
312                .collect::<BTreeSet<String>>()
313                .into_iter()
314                .collect(),
315            number_singletons,
316        })
317    }
318}
319
320fn resolve_parsed<T>(
321    flag: Option<T>,
322    env_str: Option<&str>,
323    src: Option<T>,
324    acc: Option<T>,
325    defaults: Option<T>,
326    compiled: T,
327    name: &str,
328) -> Result<T>
329where
330    T: FromStr + Copy,
331{
332    Ok(
333        resolve_parsed_opt(flag, env_str, src, acc, defaults, Some(compiled), name)?
334            .unwrap_or(compiled),
335    )
336}
337
338/// Like [`resolve_parsed`], but the value stays optional at every tier including
339/// the compiled default, so an unset knob resolves to `None` rather than a
340/// scalar fallback. Used where "unset" is itself meaningful (e.g. a native width
341/// with no cap).
342fn resolve_parsed_opt<T>(
343    flag: Option<T>,
344    env_str: Option<&str>,
345    src: Option<T>,
346    acc: Option<T>,
347    defaults: Option<T>,
348    compiled: Option<T>,
349    name: &str,
350) -> Result<Option<T>>
351where
352    T: FromStr + Copy,
353{
354    if let Some(v) = flag {
355        return Ok(Some(v));
356    }
357    if let Some(s) = env_str {
358        return s
359            .parse()
360            .map(Some)
361            .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
362    }
363    Ok(src.or(acc).or(defaults).or(compiled))
364}
365
366#[allow(clippy::too_many_arguments)]
367fn resolve_u8_ranged(
368    flag: Option<u8>,
369    env_str: Option<&str>,
370    src: Option<u8>,
371    acc: Option<u8>,
372    defaults: Option<u8>,
373    compiled: u8,
374    name: &str,
375    range: std::ops::RangeInclusive<u8>,
376) -> Result<u8> {
377    let value = if let Some(v) = flag {
378        v
379    } else if let Some(s) = env_str {
380        s.parse()
381            .map_err(|_| Error::Config(format!("invalid {name}: '{s}' (expected integer)")))?
382    } else {
383        src.or(acc).or(defaults).unwrap_or(compiled)
384    };
385    if range.contains(&value) {
386        Ok(value)
387    } else {
388        Err(Error::Config(format!(
389            "invalid {name}: '{value}' (expected {}..={})",
390            range.start(),
391            range.end()
392        )))
393    }
394}
395
396fn resolve_enum<T>(
397    flag: Option<T>,
398    env_str: Option<&str>,
399    src: Option<T>,
400    acc: Option<T>,
401    defaults: Option<T>,
402    compiled: Option<T>,
403    name: &str,
404) -> Result<Option<T>>
405where
406    T: FromStr<Err = Error> + Copy,
407{
408    if let Some(v) = flag {
409        return Ok(Some(v));
410    }
411    if let Some(s) = env_str {
412        return s
413            .parse()
414            .map(Some)
415            .map_err(|err| Error::Config(format!("invalid {name}: '{s}' ({err})")));
416    }
417    Ok(src.or(acc).or(defaults).or(compiled))
418}
419
420/// Resolve an owned-`String` knob through the standard precedence. The env value
421/// is taken verbatim (no parse), and the result stays optional so both a required
422/// knob (`naming_template`, via `unwrap_or_else`) and an optional one
423/// (`token_command`) share the one ladder. Pass `flag = None` for knobs with no
424/// CLI flag.
425fn resolve_owned(
426    flag: Option<String>,
427    env_str: Option<&str>,
428    src: Option<String>,
429    acc: Option<String>,
430    defaults: Option<String>,
431) -> Option<String> {
432    flag.or_else(|| env_str.map(str::to_owned))
433        .or(src)
434        .or(acc)
435        .or(defaults)
436}
437
438#[cfg(test)]
439mod tests;