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() -> String {
136        "AreaMode".to_owned()
137    }
138
139    fn json_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
140        let values = ["off", "copy", "mirror"]
141            .into_iter()
142            .map(|s| serde_json::Value::String(s.to_owned()))
143            .collect();
144        schemars::schema::SchemaObject {
145            instance_type: Some(schemars::schema::InstanceType::String.into()),
146            enum_values: Some(values),
147            metadata: Some(Box::new(schemars::schema::Metadata {
148                description: Some(
149                    "Deletion mode for the library area: 'off' arms deletion of \
150                     library-exclusive files, 'copy' is additive, 'mirror' deletes."
151                        .to_owned(),
152                ),
153                ..Default::default()
154            })),
155            ..Default::default()
156        }
157        .into()
158    }
159}
160
161/// Per-area mode selection for an account.
162///
163/// `library` accepts `off`/`copy`/`mirror`; `liked` and `playlists` accept
164/// `copy`/`mirror`; `playlist` overrides individual playlists by Suno id.
165/// `deny_unknown_fields` turns a mistyped key into a parse error rather than a
166/// silent no-op; the `playlist` map's dynamic ids can't use it, but its closed
167/// [`SourceMode`] values still reject a bad mode at parse time.
168#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
169#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
170#[serde(deny_unknown_fields)]
171pub struct AreasConfig {
172    pub library: Option<AreaMode>,
173    pub liked: Option<SourceMode>,
174    pub playlists: Option<SourceMode>,
175    #[serde(default)]
176    pub playlist: HashMap<String, SourceMode>,
177}
178
179/// Top-level configuration parsed from a TOML file.
180#[derive(Debug, Clone, Default, Deserialize)]
181#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
182pub struct Config {
183    #[serde(default)]
184    pub defaults: Defaults,
185    #[serde(default)]
186    pub accounts: HashMap<String, AccountConfig>,
187}
188
189impl Config {
190    /// Parse `toml_str` and validate the result.
191    ///
192    /// Validation rejects any pair of accounts whose root directories nest
193    /// inside one another. Duplicate account labels are rejected by the TOML
194    /// parser itself.
195    pub fn from_toml(toml_str: &str) -> Result<Self> {
196        let config: Self = toml::from_str(toml_str).map_err(|e| {
197            // Strip source-context lines (those containing " | ") to prevent
198            // token values from being echoed in error messages.
199            let raw = e.to_string();
200            let msg = raw
201                .lines()
202                .filter(|l| !l.contains(" | "))
203                .collect::<Vec<_>>()
204                .join("\n")
205                .trim()
206                .to_owned();
207            Error::Config(if msg.is_empty() {
208                "parse error".into()
209            } else {
210                msg
211            })
212        })?;
213        config.validate()?;
214        Ok(config)
215    }
216
217    fn validate(&self) -> Result<()> {
218        let roots: Vec<(&str, &str)> = self
219            .accounts
220            .iter()
221            .filter_map(|(label, acc)| acc.root.as_deref().map(|r| (label.as_str(), r)))
222            .collect();
223
224        for (i, (label_a, root_a)) in roots.iter().enumerate() {
225            for (label_b, root_b) in roots.iter().skip(i + 1) {
226                let a = Path::new(root_a);
227                let b = Path::new(root_b);
228                if a.starts_with(b) || b.starts_with(a) {
229                    return Err(Error::Config(format!(
230                        "account roots nest: '{label_a}' ({root_a}) and '{label_b}' ({root_b})"
231                    )));
232                }
233            }
234        }
235
236        let mut prefix_seen: HashMap<String, &str> = HashMap::new();
237        for label in self.accounts.keys() {
238            let prefix = label_to_env(label);
239            if let Some(other) = prefix_seen.get(&prefix) {
240                return Err(Error::Config(format!(
241                    "accounts '{label}' and '{other}' share env prefix '{prefix}'"
242                )));
243            }
244            prefix_seen.insert(prefix, label.as_str());
245        }
246
247        Ok(())
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn parse_empty_toml() {
257        let cfg = Config::from_toml("").unwrap();
258        assert!(cfg.accounts.is_empty());
259    }
260
261    #[test]
262    fn parse_basic_account() {
263        let toml = r#"
264            [accounts.alice]
265            token = "tok"
266            root = "/music"
267        "#;
268        let cfg = Config::from_toml(toml).unwrap();
269        let acc = &cfg.accounts["alice"];
270        assert_eq!(acc.token.as_deref(), Some("tok"));
271        assert_eq!(acc.root.as_deref(), Some("/music"));
272    }
273
274    #[test]
275    fn parse_defaults_section() {
276        let toml = r#"
277            [defaults]
278            format = "mp3"
279            concurrency = 8
280            retries = 5
281            min_newest = 2
282            animated_covers = true
283            video_cover_retention = "both"
284            animated_cover_quality = 85
285            animated_cover_max_fps = 18
286            animated_cover_max_width = 720
287            animated_cover_compression_level = 4
288        "#;
289        let cfg = Config::from_toml(toml).unwrap();
290        assert_eq!(cfg.defaults.settings.format, Some(AudioFormat::Mp3));
291        assert_eq!(cfg.defaults.settings.concurrency, Some(8));
292        assert_eq!(cfg.defaults.settings.retries, Some(5));
293        assert_eq!(cfg.defaults.settings.min_newest, Some(2));
294        assert_eq!(cfg.defaults.settings.animated_covers, Some(true));
295        assert_eq!(
296            cfg.defaults.settings.video_cover_retention,
297            Some(VideoCoverRetention::Both)
298        );
299        assert_eq!(cfg.defaults.settings.animated_cover_quality, Some(85));
300        assert_eq!(cfg.defaults.settings.animated_cover_max_fps, Some(18));
301        assert_eq!(cfg.defaults.settings.animated_cover_max_width, Some(720));
302        assert_eq!(
303            cfg.defaults.settings.animated_cover_compression_level,
304            Some(4)
305        );
306    }
307
308    #[test]
309    fn validation_nested_roots() {
310        let toml = r#"
311            [accounts.alice]
312            root = "/music"
313
314            [accounts.bob]
315            root = "/music/bob"
316        "#;
317        assert!(Config::from_toml(toml).is_err());
318    }
319
320    #[test]
321    fn validation_non_nested_roots_ok() {
322        let toml = r#"
323            [accounts.alice]
324            root = "/music/alice"
325
326            [accounts.bob]
327            root = "/music/bob"
328        "#;
329        assert!(Config::from_toml(toml).is_ok());
330    }
331
332    #[test]
333    fn invalid_toml_errors() {
334        assert!(Config::from_toml("not valid toml ][").is_err());
335    }
336
337    #[test]
338    fn duplicate_account_label_errors() {
339        // The TOML spec prohibits duplicate keys; the parser must reject this.
340        let toml = "
341            [accounts.alice]
342            token = \"tok1\"
343
344            [accounts.alice]
345            token = \"tok2\"
346        ";
347        assert!(Config::from_toml(toml).is_err());
348    }
349
350    #[test]
351    fn parse_error_does_not_echo_token() {
352        // A malformed token line must not include the raw value in the error.
353        let toml = "[accounts.alice]\ntoken = \"unterminated\n";
354        let err = Config::from_toml(toml).unwrap_err().to_string();
355        assert!(!err.contains("unterminated"), "error leaked token: {err}");
356    }
357
358    #[test]
359    fn validation_env_prefix_collision_errors() {
360        // 'my-lib' and 'my_lib' both map to SUNO_MY_LIB_* and must be rejected.
361        let toml = "
362            [accounts.my-lib]
363            [accounts.my_lib]
364        ";
365        assert!(Config::from_toml(toml).is_err());
366    }
367
368    #[test]
369    fn areas_parse_full_table() {
370        let toml = r#"
371            [accounts.alice]
372            token = "t"
373            [accounts.alice.areas]
374            library = "off"
375            liked = "copy"
376            playlists = "mirror"
377            [accounts.alice.areas.playlist]
378            "pl_abc123" = "mirror"
379            "pl_def456" = "copy"
380        "#;
381        let cfg = Config::from_toml(toml).unwrap();
382        let areas = cfg.accounts["alice"].areas.as_ref().unwrap();
383        assert_eq!(areas.library, Some(AreaMode::Off));
384        assert_eq!(areas.liked, Some(SourceMode::Copy));
385        assert_eq!(areas.playlists, Some(SourceMode::Mirror));
386        assert_eq!(areas.playlist["pl_abc123"], SourceMode::Mirror);
387        assert_eq!(areas.playlist["pl_def456"], SourceMode::Copy);
388    }
389
390    #[test]
391    fn areas_library_accepts_copy_and_mirror() {
392        for (raw, expect) in [
393            ("copy", AreaMode::Mode(SourceMode::Copy)),
394            ("mirror", AreaMode::Mode(SourceMode::Mirror)),
395        ] {
396            let toml =
397                format!("[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibrary = \"{raw}\"\n");
398            let cfg = Config::from_toml(&toml).unwrap();
399            assert_eq!(
400                cfg.accounts["a"].areas.as_ref().unwrap().library,
401                Some(expect)
402            );
403        }
404    }
405
406    #[test]
407    fn areas_bad_mode_errors() {
408        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nliked = \"miror\"\n";
409        assert!(Config::from_toml(toml).is_err());
410    }
411
412    #[test]
413    fn areas_bad_playlist_mode_errors() {
414        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas.playlist]\n\"pl1\" = \"off\"\n";
415        // `off` is a library-only value; a per-playlist entry must be copy/mirror.
416        assert!(Config::from_toml(toml).is_err());
417    }
418
419    #[test]
420    fn areas_unknown_field_errors() {
421        // D7: a mistyped key (libary) is a parse error, not a silent no-op.
422        let toml = "[accounts.a]\ntoken = \"t\"\n[accounts.a.areas]\nlibary = \"off\"\n";
423        assert!(Config::from_toml(toml).is_err());
424    }
425
426    #[test]
427    fn areas_absent_is_none() {
428        let toml = "[accounts.a]\ntoken = \"t\"\n";
429        assert!(
430            Config::from_toml(toml).unwrap().accounts["a"]
431                .areas
432                .is_none()
433        );
434    }
435}