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            Layers {
46                flag: flags.settings.format,
47                env: env_val("FORMAT"),
48                src: src.and_then(|s| s.settings.format),
49                acc: acc.settings.format,
50                defaults: self.defaults.settings.format,
51                name: "FORMAT",
52            },
53            None,
54        )?
55        .unwrap_or(AudioFormat::Flac);
56
57        let concurrency = resolve_parsed(
58            Layers {
59                flag: flags.settings.concurrency,
60                env: env_val("CONCURRENCY"),
61                src: src.and_then(|s| s.settings.concurrency),
62                acc: acc.settings.concurrency,
63                defaults: self.defaults.settings.concurrency,
64                name: "CONCURRENCY",
65            },
66            4,
67        )?;
68
69        let retries = resolve_parsed(
70            Layers {
71                flag: flags.settings.retries,
72                env: env_val("RETRIES"),
73                src: src.and_then(|s| s.settings.retries),
74                acc: acc.settings.retries,
75                defaults: self.defaults.settings.retries,
76                name: "RETRIES",
77            },
78            3,
79        )?;
80
81        let min_newest = resolve_parsed(
82            Layers {
83                flag: flags.settings.min_newest,
84                env: env_val("MIN_NEWEST"),
85                src: src.and_then(|s| s.settings.min_newest),
86                acc: acc.settings.min_newest,
87                defaults: self.defaults.settings.min_newest,
88                name: "MIN_NEWEST",
89            },
90            1,
91        )?;
92
93        let animated_covers = resolve_parsed(
94            Layers {
95                flag: flags.settings.animated_covers,
96                env: env_val("ANIMATED_COVERS"),
97                src: src.and_then(|s| s.settings.animated_covers),
98                acc: acc.settings.animated_covers,
99                defaults: self.defaults.settings.animated_covers,
100                name: "ANIMATED_COVERS",
101            },
102            false,
103        )?;
104
105        let details_sidecar = resolve_parsed(
106            Layers {
107                flag: flags.settings.details_sidecar,
108                env: env_val("DETAILS_SIDECAR"),
109                src: src.and_then(|s| s.settings.details_sidecar),
110                acc: acc.settings.details_sidecar,
111                defaults: self.defaults.settings.details_sidecar,
112                name: "DETAILS_SIDECAR",
113            },
114            false,
115        )?;
116
117        let lyrics_sidecar = resolve_parsed(
118            Layers {
119                flag: flags.settings.lyrics_sidecar,
120                env: env_val("LYRICS_SIDECAR"),
121                src: src.and_then(|s| s.settings.lyrics_sidecar),
122                acc: acc.settings.lyrics_sidecar,
123                defaults: self.defaults.settings.lyrics_sidecar,
124                name: "LYRICS_SIDECAR",
125            },
126            false,
127        )?;
128
129        let lrc_sidecar = resolve_parsed(
130            Layers {
131                flag: flags.settings.lrc_sidecar,
132                env: env_val("LRC_SIDECAR"),
133                src: src.and_then(|s| s.settings.lrc_sidecar),
134                acc: acc.settings.lrc_sidecar,
135                defaults: self.defaults.settings.lrc_sidecar,
136                name: "LRC_SIDECAR",
137            },
138            false,
139        )?;
140
141        let video_mp4 = resolve_parsed(
142            Layers {
143                flag: flags.settings.video_mp4,
144                env: env_val("VIDEO_MP4"),
145                src: src.and_then(|s| s.settings.video_mp4),
146                acc: acc.settings.video_mp4,
147                defaults: self.defaults.settings.video_mp4,
148                name: "VIDEO_MP4",
149            },
150            false,
151        )?;
152
153        let download_stems = resolve_parsed(
154            Layers {
155                flag: flags.settings.download_stems,
156                env: env_val("DOWNLOAD_STEMS"),
157                src: src.and_then(|s| s.settings.download_stems),
158                acc: acc.settings.download_stems,
159                defaults: self.defaults.settings.download_stems,
160                name: "DOWNLOAD_STEMS",
161            },
162            false,
163        )?;
164
165        let stem_format = resolve_enum(
166            Layers {
167                flag: flags.settings.stem_format,
168                env: env_val("STEM_FORMAT"),
169                src: src.and_then(|s| s.settings.stem_format),
170                acc: acc.settings.stem_format,
171                defaults: self.defaults.settings.stem_format,
172                name: "STEM_FORMAT",
173            },
174            None,
175        )?
176        .unwrap_or_default();
177
178        let video_cover_retention = resolve_enum(
179            Layers {
180                flag: flags.settings.video_cover_retention,
181                env: env_val("VIDEO_COVER_RETENTION"),
182                src: src.and_then(|s| s.settings.video_cover_retention),
183                acc: acc.settings.video_cover_retention,
184                defaults: self.defaults.settings.video_cover_retention,
185                name: "VIDEO_COVER_RETENTION",
186            },
187            None,
188        )?;
189        // `video_cover_retention` is the unified control for the album
190        // video-cover artifacts: `webp`/`both` keep the transcoded `cover.webp`,
191        // `mp4`/`both` the raw `cover.mp4`. The standalone music video
192        // (`video_url`) keeps its own `video_mp4` toggle, untouched here.
193        let (animated_covers, raw_animated_cover) = match video_cover_retention {
194            Some(retention) => (retention.keeps_webp(), retention.keeps_mp4()),
195            None => (animated_covers, false),
196        };
197
198        let defaults_webp = WebpEncodeSettings::default();
199        let animated_cover_quality = resolve_u8_ranged(
200            Layers {
201                flag: flags.settings.animated_cover_quality,
202                env: env_val("ANIMATED_COVER_QUALITY"),
203                src: src.and_then(|s| s.settings.animated_cover_quality),
204                acc: acc.settings.animated_cover_quality,
205                defaults: self.defaults.settings.animated_cover_quality,
206                name: "ANIMATED_COVER_QUALITY",
207            },
208            defaults_webp.quality,
209            0..=100,
210        )?;
211        let animated_cover_max_fps = resolve_parsed(
212            Layers {
213                flag: flags.settings.animated_cover_max_fps,
214                env: env_val("ANIMATED_COVER_MAX_FPS"),
215                src: src.and_then(|s| s.settings.animated_cover_max_fps),
216                acc: acc.settings.animated_cover_max_fps,
217                defaults: self.defaults.settings.animated_cover_max_fps,
218                name: "ANIMATED_COVER_MAX_FPS",
219            },
220            defaults_webp.max_fps,
221        )?;
222        let animated_cover_max_width = resolve_parsed_opt(
223            Layers {
224                flag: flags.settings.animated_cover_max_width,
225                env: env_val("ANIMATED_COVER_MAX_WIDTH"),
226                src: src.and_then(|s| s.settings.animated_cover_max_width),
227                acc: acc.settings.animated_cover_max_width,
228                defaults: self.defaults.settings.animated_cover_max_width,
229                name: "ANIMATED_COVER_MAX_WIDTH",
230            },
231            defaults_webp.max_width,
232        )?;
233        let animated_cover_compression_level = resolve_u8_ranged(
234            Layers {
235                flag: flags.settings.animated_cover_compression_level,
236                env: env_val("ANIMATED_COVER_COMPRESSION_LEVEL"),
237                src: src.and_then(|s| s.settings.animated_cover_compression_level),
238                acc: acc.settings.animated_cover_compression_level,
239                defaults: self.defaults.settings.animated_cover_compression_level,
240                name: "ANIMATED_COVER_COMPRESSION_LEVEL",
241            },
242            defaults_webp.compression_level,
243            0..=4,
244        )?;
245        let animated_cover_lossless = resolve_parsed(
246            Layers {
247                flag: flags.settings.animated_cover_lossless,
248                env: env_val("ANIMATED_COVER_LOSSLESS"),
249                src: src.and_then(|s| s.settings.animated_cover_lossless),
250                acc: acc.settings.animated_cover_lossless,
251                defaults: self.defaults.settings.animated_cover_lossless,
252                name: "ANIMATED_COVER_LOSSLESS",
253            },
254            defaults_webp.lossless,
255        )?;
256
257        let naming_template = resolve_owned(
258            flags.settings.naming_template.clone(),
259            env_val("NAMING_TEMPLATE"),
260            src.and_then(|s| s.settings.naming_template.clone()),
261            acc.settings.naming_template.clone(),
262            self.defaults.settings.naming_template.clone(),
263        )
264        .unwrap_or_else(|| crate::naming::DEFAULT_TEMPLATE.to_owned());
265
266        let character_set = resolve_enum(
267            Layers {
268                flag: flags.settings.character_set,
269                env: env_val("CHARACTER_SET"),
270                src: src.and_then(|s| s.settings.character_set),
271                acc: acc.settings.character_set,
272                defaults: self.defaults.settings.character_set,
273                name: "CHARACTER_SET",
274            },
275            None,
276        )?
277        .unwrap_or(CharacterSet::Unicode);
278
279        let number_singletons = resolve_parsed(
280            Layers {
281                flag: flags.settings.number_singletons,
282                env: env_val("NUMBER_SINGLETONS"),
283                src: src.and_then(|s| s.settings.number_singletons),
284                acc: acc.settings.number_singletons,
285                defaults: self.defaults.settings.number_singletons,
286                name: "NUMBER_SINGLETONS",
287            },
288            true,
289        )?;
290
291        let token = flags
292            .token
293            .clone()
294            .or_else(|| env.get(&format!("SUNO_{label_env}_TOKEN")).cloned())
295            .or_else(|| env.get("SUNO_TOKEN").cloned());
296
297        let token_command = resolve_owned(
298            None,
299            env_val("TOKEN_COMMAND"),
300            src.and_then(|s| s.settings.token_command.clone()),
301            acc.settings.token_command.clone(),
302            self.defaults.settings.token_command.clone(),
303        );
304
305        Ok(EffectiveSettings {
306            token,
307            stored_token: acc.token.clone(),
308            token_command,
309            account_id: acc.account_id.clone(),
310            format,
311            concurrency,
312            retries,
313            min_newest,
314            animated_covers,
315            raw_animated_cover,
316            video_cover_retention: match (animated_covers, raw_animated_cover) {
317                (false, false) => VideoCoverRetention::Neither,
318                (true, false) => VideoCoverRetention::Webp,
319                (false, true) => VideoCoverRetention::Mp4,
320                (true, true) => VideoCoverRetention::Both,
321            },
322            animated_cover_webp: WebpEncodeSettings {
323                quality: animated_cover_quality,
324                max_fps: animated_cover_max_fps,
325                max_width: animated_cover_max_width,
326                lossless: animated_cover_lossless,
327                compression_level: animated_cover_compression_level,
328            },
329            details_sidecar,
330            lyrics_sidecar,
331            lrc_sidecar,
332            video_mp4,
333            download_stems,
334            stem_format,
335            naming_template,
336            character_set,
337            areas: acc.areas.clone(),
338            album_overrides: acc
339                .albums
340                .iter()
341                .filter(|(_, name)| !name.trim().is_empty())
342                .map(|(root_id, name)| (root_id.clone(), name.trim().to_owned()))
343                .collect(),
344            lead_tracks: acc
345                .lead_tracks
346                .iter()
347                .map(|entry| entry.trim())
348                .filter(|entry| !entry.is_empty())
349                .map(str::to_owned)
350                .collect::<BTreeSet<String>>()
351                .into_iter()
352                .collect(),
353            number_singletons,
354        })
355    }
356}
357
358/// The layered sources for one setting, in precedence order: a CLI flag beats
359/// the environment, which beats the `[sources.*]` table, which beats the
360/// account, which beats `[defaults]`. `name` labels the knob in error messages
361/// and is also its environment-variable suffix.
362///
363/// Grouped into a struct rather than passed positionally because the five tiers
364/// are adjacent same-typed `Option`s: transposing `src` and `acc` in a call
365/// compiles cleanly and silently inverts the precedence. Naming them at the call
366/// site makes that class of mistake visible. The compiled fallback stays a
367/// separate argument because its type varies by resolver (`T`, `Option<T>`, `u8`).
368struct Layers<'a, T> {
369    flag: Option<T>,
370    env: Option<&'a str>,
371    src: Option<T>,
372    acc: Option<T>,
373    defaults: Option<T>,
374    name: &'a str,
375}
376
377fn resolve_parsed<T>(layers: Layers<'_, T>, compiled: T) -> Result<T>
378where
379    T: FromStr + Copy,
380{
381    Ok(resolve_parsed_opt(layers, Some(compiled))?.unwrap_or(compiled))
382}
383
384/// Like [`resolve_parsed`], but the value stays optional at every tier including
385/// the compiled default, so an unset knob resolves to `None` rather than a
386/// scalar fallback. Used where "unset" is itself meaningful (e.g. a native width
387/// with no cap).
388fn resolve_parsed_opt<T>(layers: Layers<'_, T>, compiled: Option<T>) -> Result<Option<T>>
389where
390    T: FromStr + Copy,
391{
392    let Layers {
393        flag,
394        env,
395        src,
396        acc,
397        defaults,
398        name,
399    } = layers;
400    if let Some(v) = flag {
401        return Ok(Some(v));
402    }
403    if let Some(s) = env {
404        return s
405            .parse()
406            .map(Some)
407            .map_err(|_| Error::Config(format!("invalid {name}: '{s}'")));
408    }
409    Ok(src.or(acc).or(defaults).or(compiled))
410}
411
412fn resolve_u8_ranged(
413    layers: Layers<'_, u8>,
414    compiled: u8,
415    range: std::ops::RangeInclusive<u8>,
416) -> Result<u8> {
417    let Layers {
418        flag,
419        env,
420        src,
421        acc,
422        defaults,
423        name,
424    } = layers;
425    let value = if let Some(v) = flag {
426        v
427    } else if let Some(s) = env {
428        s.parse()
429            .map_err(|_| Error::Config(format!("invalid {name}: '{s}' (expected integer)")))?
430    } else {
431        src.or(acc).or(defaults).unwrap_or(compiled)
432    };
433    if range.contains(&value) {
434        Ok(value)
435    } else {
436        Err(Error::Config(format!(
437            "invalid {name}: '{value}' (expected {}..={})",
438            range.start(),
439            range.end()
440        )))
441    }
442}
443
444fn resolve_enum<T>(layers: Layers<'_, T>, compiled: Option<T>) -> Result<Option<T>>
445where
446    T: FromStr<Err = Error> + Copy,
447{
448    let Layers {
449        flag,
450        env,
451        src,
452        acc,
453        defaults,
454        name,
455    } = layers;
456    if let Some(v) = flag {
457        return Ok(Some(v));
458    }
459    if let Some(s) = env {
460        return s
461            .parse()
462            .map(Some)
463            .map_err(|err| Error::Config(format!("invalid {name}: '{s}' ({err})")));
464    }
465    Ok(src.or(acc).or(defaults).or(compiled))
466}
467
468/// Resolve an owned-`String` knob through the standard precedence. The env value
469/// is taken verbatim (no parse), and the result stays optional so both a required
470/// knob (`naming_template`, via `unwrap_or_else`) and an optional one
471/// (`token_command`) share the one ladder. Pass `flag = None` for knobs with no
472/// CLI flag.
473fn resolve_owned(
474    flag: Option<String>,
475    env_str: Option<&str>,
476    src: Option<String>,
477    acc: Option<String>,
478    defaults: Option<String>,
479) -> Option<String> {
480    flag.or_else(|| env_str.map(str::to_owned))
481        .or(src)
482        .or(acc)
483        .or(defaults)
484}
485
486#[cfg(test)]
487mod tests;