Skip to main content

suno_core/config/
shape.rs

1//! The TOML input shape and its parse-time validation.
2//!
3//! These are the deserialisation targets; [`Config`] is the top-level file,
4//! and [`Config::from_toml`] parses and validates it (no IO).
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use serde::Deserialize;
10
11use crate::error::{Error, Result};
12use crate::naming::CharacterSet;
13use crate::vocab::{AudioFormat, SourceMode, StemFormat, VideoCoverRetention};
14
15use super::label_to_env;
16
17/// The overridable settings block, shared verbatim by every precedence tier.
18///
19/// A new knob is added here once and every tier that flattens it ([`Defaults`],
20/// [`AccountConfig`], [`SourceConfig`], and the CLI [`FlagOverrides`]) gains it,
21/// rather than being mirrored where forgetting one would silently drop it.
22#[derive(Debug, Clone, Default, Deserialize)]
23#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
24pub struct Settings {
25    pub format: Option<AudioFormat>,
26    pub concurrency: Option<u32>,
27    pub retries: Option<u32>,
28    pub min_newest: Option<u32>,
29    /// The command whose stdout mints a token. Resolved from the
30    /// `SUNO_[<LABEL>_]TOKEN_COMMAND` env tiers then the config keys. There is
31    /// deliberately no `--token-command` flag, so it is never read from
32    /// [`FlagOverrides`]; set it in config or the environment.
33    pub token_command: Option<String>,
34    pub animated_covers: Option<bool>,
35    pub video_cover_retention: Option<VideoCoverRetention>,
36    pub animated_cover_quality: Option<u8>,
37    pub animated_cover_max_fps: Option<u32>,
38    pub animated_cover_max_width: Option<u32>,
39    pub animated_cover_compression_level: Option<u8>,
40    pub animated_cover_lossless: Option<bool>,
41    pub details_sidecar: Option<bool>,
42    pub lyrics_sidecar: Option<bool>,
43    pub lrc_sidecar: Option<bool>,
44    pub video_mp4: Option<bool>,
45    pub download_stems: Option<bool>,
46    pub stem_format: Option<StemFormat>,
47    pub naming_template: Option<String>,
48    pub character_set: Option<CharacterSet>,
49    /// Whether a single-track (lone) lineage album is given a track number. When
50    /// unset it defaults to `true`; `false` leaves singletons unnumbered so a
51    /// `{track2}` prefix does not decorate a standalone song.
52    pub number_singletons: Option<bool>,
53}
54
55/// Global default settings applied when no account or source override applies.
56#[derive(Debug, Clone, Default, Deserialize)]
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
58pub struct Defaults {
59    #[serde(flatten)]
60    pub settings: Settings,
61}
62
63/// Per-source overridable settings within an account.
64#[derive(Debug, Clone, Default, Deserialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66pub struct SourceConfig {
67    #[serde(flatten)]
68    pub settings: Settings,
69}
70
71/// Configuration for a single named account.
72#[derive(Debug, Clone, Default, Deserialize)]
73#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74pub struct AccountConfig {
75    pub token: Option<String>,
76    pub root: Option<String>,
77    /// Optional Suno user id to assert this account authenticates as, refusing
78    /// to run on a mismatch (a belt-and-braces check alongside the on-disk
79    /// owner pin in the lineage store).
80    pub account_id: Option<String>,
81    #[serde(flatten)]
82    pub settings: Settings,
83    #[serde(default)]
84    pub sources: HashMap<String, SourceConfig>,
85    /// Per-area mode selection (`sync` vs `copy`) for this account's library,
86    /// liked feed, and playlists. Absent means the classic single-verb run.
87    pub areas: Option<AreasConfig>,
88    /// Manual album-name overrides, keyed by the album's stable lineage root id
89    /// (`<root_id> = "Preferred Name"`). Account-wide, never per-source, since
90    /// album identity is the lineage root. An empty or whitespace-only value is
91    /// ignored, so a stray key cannot blank an album.
92    #[serde(default)]
93    pub albums: HashMap<String, String>,
94    /// Clip ids (or unique id prefixes, e.g. the 8-char code from a filename)
95    /// flagged as their lineage album's lead: each is promoted to track 1,
96    /// shifting the rest down. Account-wide; a clip's album is inferred from its
97    /// resolved root, so the album is never named here.
98    #[serde(default)]
99    pub lead_tracks: Vec<String>,
100}
101
102/// How a single area treats deletion, including the library-only `off` value.
103///
104/// `off` is expressible only for the library area: it deliberately arms deletion
105/// of library-exclusive files by suppressing the implicit copy-protector, so a
106/// typo can never silently disarm that safety. `copy` and `mirror` map straight
107/// onto the matching [`SourceMode`].
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum AreaMode {
110    /// Suppress the implicit library copy-protector (arm library deletions).
111    Off,
112    /// Treat the area with the given [`SourceMode`].
113    Mode(SourceMode),
114}
115
116impl<'de> Deserialize<'de> for AreaMode {
117    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
118    where
119        D: serde::Deserializer<'de>,
120    {
121        let raw = String::deserialize(deserializer)?;
122        match raw.as_str() {
123            "off" => Ok(AreaMode::Off),
124            "copy" => Ok(AreaMode::Mode(SourceMode::Copy)),
125            "mirror" => Ok(AreaMode::Mode(SourceMode::Mirror)),
126            other => Err(serde::de::Error::custom(format!(
127                "unknown area mode '{other}', expected 'off', 'copy', or 'mirror'"
128            ))),
129        }
130    }
131}
132
133#[cfg(feature = "schema")]
134impl schemars::JsonSchema for AreaMode {
135    fn schema_name() -> std::borrow::Cow<'static, str> {
136        "AreaMode".into()
137    }
138
139    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
140        schemars::json_schema!({
141            "type": "string",
142            "enum": ["off", "copy", "mirror"],
143            "description": "Deletion mode for the library area: 'off' arms deletion of \
144                library-exclusive files, 'copy' is additive, 'mirror' deletes.",
145        })
146    }
147}
148
149/// Per-area mode selection for an account.
150///
151/// `library` accepts `off`/`copy`/`mirror`; `liked` and `playlists` accept
152/// `copy`/`mirror`; `playlist` overrides individual playlists by Suno id.
153/// `deny_unknown_fields` turns a mistyped key into a parse error rather than a
154/// silent no-op; the `playlist` map's dynamic ids can't use it, but its closed
155/// [`SourceMode`] values still reject a bad mode at parse time.
156#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
157#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
158#[serde(deny_unknown_fields)]
159pub struct AreasConfig {
160    pub library: Option<AreaMode>,
161    pub liked: Option<SourceMode>,
162    pub playlists: Option<SourceMode>,
163    #[serde(default)]
164    pub playlist: HashMap<String, SourceMode>,
165}
166
167/// Top-level configuration parsed from a TOML file.
168#[derive(Debug, Clone, Default, Deserialize)]
169#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
170pub struct Config {
171    #[serde(default)]
172    pub defaults: Defaults,
173    #[serde(default)]
174    pub accounts: HashMap<String, AccountConfig>,
175}
176
177impl Config {
178    /// Parse `toml_str` and validate the result.
179    ///
180    /// Validation rejects any pair of accounts whose root directories nest
181    /// inside one another. Duplicate account labels are rejected by the TOML
182    /// parser itself.
183    pub fn from_toml(toml_str: &str) -> Result<Self> {
184        let config: Self = toml::from_str(toml_str).map_err(|e| {
185            // Strip source-context lines (those containing " | ") to prevent
186            // token values from being echoed in error messages.
187            let raw = e.to_string();
188            let msg = raw
189                .lines()
190                .filter(|l| !l.contains(" | "))
191                .collect::<Vec<_>>()
192                .join("\n")
193                .trim()
194                .to_owned();
195            Error::Config(if msg.is_empty() {
196                "parse error".into()
197            } else {
198                msg
199            })
200        })?;
201        config.validate()?;
202        Ok(config)
203    }
204
205    fn validate(&self) -> Result<()> {
206        let roots: Vec<(&str, &str)> = self
207            .accounts
208            .iter()
209            .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
210            .collect();
211
212        for (i, (label_a, root_a)) in roots.iter().enumerate() {
213            for (label_b, root_b) in roots.iter().skip(i + 1) {
214                let a = Path::new(root_a);
215                let b = Path::new(root_b);
216                if a.starts_with(b) || b.starts_with(a) {
217                    return Err(Error::Config(format!(
218                        "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
219                    )));
220                }
221            }
222        }
223
224        let mut prefix_seen: HashMap<String, &str> = HashMap::new();
225        for label in self.accounts.keys() {
226            let prefix = label_to_env(label);
227            if let Some(other) = prefix_seen.get(&prefix) {
228                return Err(Error::Config(format!(
229                    "accounts '{label}' and '{other}' share env prefix '{prefix}'"
230                )));
231            }
232            prefix_seen.insert(prefix, label.as_str());
233        }
234
235        Ok(())
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn parse_empty_toml() {
245        let cfg = Config::from_toml("").unwrap();
246        assert!(cfg.accounts.is_empty());
247    }
248
249    #[test]
250    fn parse_basic_account() {
251        let toml = r#"
252            [accounts.alice]
253            token = "tok"
254            root = "/music"
255        "#;
256        let cfg = Config::from_toml(toml).unwrap();
257        let acc = &cfg.accounts["alice"];
258        assert_eq!(acc.token.as_deref(), Some("tok"));
259        assert_eq!(acc.root.as_deref(), Some("/music"));
260    }
261
262    #[test]
263    fn parse_defaults_section() {
264        let toml = r#"
265            [defaults]
266            format = "mp3"
267            concurrency = 8
268            retries = 5
269            min_newest = 2
270            animated_covers = true
271            video_cover_retention = "both"
272            animated_cover_quality = 85
273            animated_cover_max_fps = 18
274            animated_cover_max_width = 720
275            animated_cover_compression_level = 4
276        "#;
277        let cfg = Config::from_toml(toml).unwrap();
278        assert_eq!(cfg.defaults.settings.format, Some(AudioFormat::Mp3));
279        assert_eq!(cfg.defaults.settings.concurrency, Some(8));
280        assert_eq!(cfg.defaults.settings.retries, Some(5));
281        assert_eq!(cfg.defaults.settings.min_newest, Some(2));
282        assert_eq!(cfg.defaults.settings.animated_covers, Some(true));
283        assert_eq!(
284            cfg.defaults.settings.video_cover_retention,
285            Some(VideoCoverRetention::Both)
286        );
287        assert_eq!(cfg.defaults.settings.animated_cover_quality, Some(85));
288        assert_eq!(cfg.defaults.settings.animated_cover_max_fps, Some(18));
289        assert_eq!(cfg.defaults.settings.animated_cover_max_width, Some(720));
290        assert_eq!(
291            cfg.defaults.settings.animated_cover_compression_level,
292            Some(4)
293        );
294    }
295
296    #[test]
297    fn validation_nested_roots() {
298        let toml = r#"
299            [accounts.alice]
300            root = "/music"
301
302            [accounts.bob]
303            root = "/music/bob"
304        "#;
305        assert!(Config::from_toml(toml).is_err());
306    }
307
308    #[test]
309    fn validation_non_nested_roots_ok() {
310        let toml = r#"
311            [accounts.alice]
312            root = "/music/alice"
313
314            [accounts.bob]
315            root = "/music/bob"
316        "#;
317        assert!(Config::from_toml(toml).is_ok());
318    }
319
320    #[test]
321    fn invalid_toml_errors() {
322        assert!(Config::from_toml("not valid toml ][").is_err());
323    }
324
325    #[test]
326    fn duplicate_account_label_errors() {
327        // The TOML spec prohibits duplicate keys; the parser must reject this.
328        let toml = "
329            [accounts.alice]
330            token = \"tok1\"
331
332            [accounts.alice]
333            token = \"tok2\"
334        ";
335        assert!(Config::from_toml(toml).is_err());
336    }
337
338    #[test]
339    fn parse_error_does_not_echo_token() {
340        // A malformed token line must not include the raw value in the error.
341        let toml = "[accounts.alice]\ntoken = \"unterminated\n";
342        let err = Config::from_toml(toml).unwrap_err().to_string();
343        assert!(!err.contains("unterminated"), "error leaked token: {err}");
344    }
345
346    #[test]
347    fn validation_env_prefix_collision_errors() {
348        // 'my-lib' and 'my_lib' both map to SUNO_MY_LIB_* and must be rejected.
349        let toml = "
350            [accounts.my-lib]
351            [accounts.my_lib]
352        ";
353        assert!(Config::from_toml(toml).is_err());
354    }
355
356    #[test]
357    fn areas_parse_full_table() {
358        let toml = r#"
359            [accounts.alice]
360            token = "t"
361            [accounts.alice.areas]
362            library = "off"
363            liked = "copy"
364            playlists = "mirror"
365            [accounts.alice.areas.playlist]
366            "pl_abc123" = "mirror"
367            "pl_def456" = "copy"
368        "#;
369        let cfg = Config::from_toml(toml).unwrap();
370        let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
371        assert_eq!(areas.library, Some(AreaMode::Off));
372        assert_eq!(areas.liked, Some(SourceMode::Copy));
373        assert_eq!(areas.playlists, Some(SourceMode::Mirror));
374        assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
375        assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
376    }
377
378    #[test]
379    fn areas_library_accepts_copy_and_mirror() {
380        for (raw, expect) in [
381            ("copy", AreaMode::Mode(SourceMode::Copy)),
382            ("mirror", AreaMode::Mode(SourceMode::Mirror)),
383        ] {
384            let toml =
385                format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
386            let cfg = Config::from_toml(&toml).unwrap();
387            assert_eq!(
388                cfg.accounts["a"].areas.as_ref().unwrap().library,
389                Some(expect)
390            );
391        }
392    }
393
394    #[test]
395    fn areas_bad_mode_errors() {
396        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
397        assert!(Config::from_toml(toml).is_err());
398    }
399
400    #[test]
401    fn areas_bad_playlist_mode_errors() {
402        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
403        // `off` is a library-only value; a per-playlist entry must be copy/mirror.
404        assert!(Config::from_toml(toml).is_err());
405    }
406
407    #[test]
408    fn areas_unknown_field_errors() {
409        // D7: a mistyped key (libary) is a parse error, not a silent no-op.
410        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
411        assert!(Config::from_toml(toml).is_err());
412    }
413
414    #[test]
415    fn areas_absent_is_none() {
416        let toml = "[accounts.a]\ntoken = \"t\"\n";
417        assert!(
418            Config::from_toml(toml).unwrap().accounts["a"]
419                .areas
420                .is_none()
421        );
422    }
423}