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