rs-suno 0.9.0

A download-only command-line tool for mirroring your Suno.ai library.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
//! The clap command surface: the top-level parser, global options, and every
//! subcommand's arguments.
//!
//! Values that feed the engine's precedence resolution (`token`, `format`,
//! `retries`, ...) deliberately carry no clap `env`: the per-account
//! environment tier (`SUNO_<LABEL>_TOKEN`) lives in
//! [`suno_core::Config::resolve`], so letting clap pre-read the global env here
//! would shadow it. Globals that the engine does not resolve (`--account`,
//! `--config`, `--dry-run`, `--yes`) keep their clap `env` for convenience.

use std::path::PathBuf;

use clap::{Args, Parser, Subcommand, ValueEnum};
use suno_core::AudioFormat;

/// A download-only tool for mirroring your Suno.ai library.
#[derive(Parser, Debug)]
#[command(name = "suno", version, about, long_about = None)]
pub struct Cli {
    #[command(flatten)]
    pub global: GlobalArgs,
    #[command(subcommand)]
    pub command: Command,
}

/// Options accepted by `suno` itself, valid before or after any subcommand.
#[derive(Args, Debug, Clone, Default)]
pub struct GlobalArgs {
    /// Run against one configured account.
    #[arg(long, global = true, env = "SUNO_ACCOUNT", value_name = "LABEL")]
    pub account: Option<String>,
    /// Run every configured account in isolation.
    #[arg(long, global = true, conflicts_with = "account")]
    pub all: bool,
    /// Path to the config file.
    #[arg(long, global = true, env = "SUNO_CONFIG", value_name = "PATH")]
    pub config: Option<PathBuf>,
    /// Report changes without writing to disk.
    #[arg(short = 'n', long, global = true, env = "SUNO_DRY_RUN")]
    pub dry_run: bool,
    /// Increase verbosity (repeatable: -vv for debug).
    #[arg(short = 'v', long, global = true, action = clap::ArgAction::Count)]
    pub verbose: u8,
    /// Decrease verbosity (repeatable: -qq for errors only).
    #[arg(short = 'q', long, global = true, action = clap::ArgAction::Count)]
    pub quiet: u8,
    /// Skip confirmation prompts (e.g. destructive sync).
    #[arg(short = 'y', long, global = true, env = "SUNO_YES")]
    pub yes: bool,
    /// Suno `__client` token. Never printed. Overrides config and env.
    #[arg(long, global = true, hide_env_values = true, value_name = "TOKEN")]
    pub token: Option<String>,
}

impl GlobalArgs {
    /// The net verbosity level: `-v` adds, `-q` subtracts, default 0.
    pub fn verbosity(&self) -> i8 {
        i8::try_from(self.verbose).unwrap_or(i8::MAX) - i8::try_from(self.quiet).unwrap_or(i8::MAX)
    }
}

/// The subcommand to run.
#[derive(Subcommand, Debug)]
pub enum Command {
    /// Mirror a source: download, update, and remove local files.
    Sync(SyncArgs),
    /// Download and update, never delete.
    Copy(SyncArgs),
    /// Report what sync or copy would change without touching disk.
    Check(CheckArgs),
    /// List clips in your Suno library.
    Ls(LsArgs),
    /// List clips as newline-delimited JSON.
    Lsjson(LsArgs),
    /// Download a specific clip by ID or URL.
    Fetch(FetchArgs),
    /// Manage the configuration file.
    Config(ConfigArgs),
    /// Manage authentication.
    Auth(AuthArgs),
    /// Print version and environment information.
    Version,
    /// Emit a shell completion script.
    Completions(CompletionsArgs),
}

/// Audio format for downloaded clips, mapped onto [`AudioFormat`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lower")]
pub enum AudioFmt {
    Mp3,
    Flac,
    Wav,
}

impl From<AudioFmt> for AudioFormat {
    fn from(value: AudioFmt) -> Self {
        match value {
            AudioFmt::Mp3 => AudioFormat::Mp3,
            AudioFmt::Flac => AudioFormat::Flac,
            AudioFmt::Wav => AudioFormat::Wav,
        }
    }
}

/// Output format for `ls`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
#[value(rename_all = "lower")]
pub enum OutputFormat {
    #[default]
    Text,
    Json,
}

/// Flags shared by `sync` and `copy`.
#[derive(Args, Debug, Clone, Default)]
pub struct SyncArgs {
    /// Local directory to mirror into (defaults to the account's configured root).
    #[arg(value_name = "DEST")]
    pub dest: Option<PathBuf>,
    /// Audio format: mp3, flac, wav.
    #[arg(long, value_enum, value_name = "FORMAT")]
    pub format: Option<AudioFmt>,
    /// Mirror only the N most recent clips.
    #[arg(long, value_name = "N")]
    pub limit: Option<usize>,
    /// Mirror clips newer than a relative time (e.g. 7d, 2w, last-run).
    #[arg(long, value_name = "SPEC")]
    pub since: Option<String>,
    /// Minimum newest clips kept when a recency filter applies.
    #[arg(long, value_name = "N")]
    pub min_newest: Option<u32>,
    /// Download retry attempts per clip.
    #[arg(long, value_name = "N")]
    pub retries: Option<u32>,
    /// Simultaneous downloads (reserved; downloads are currently sequential).
    #[arg(long, value_name = "N", hide = true)]
    pub concurrency: Option<u32>,
    /// Also write an animated cover.webp from each clip's video preview.
    #[arg(long)]
    pub animated_covers: bool,
    /// Re-pin this library to the authenticated account (use only when you
    /// deliberately point it at a different Suno account).
    #[arg(long)]
    pub allow_account_change: bool,
    /// Also write a plain-text `.details.txt` sidecar next to each song.
    #[arg(long)]
    pub details_sidecar: bool,
    /// Also write a plain-text `.lyrics.txt` sidecar next to each song.
    #[arg(long)]
    pub lyrics_sidecar: bool,
    /// Mirror only your liked songs. A scoped run never deletes and does not
    /// maintain `.m3u8` playlists.
    #[arg(long)]
    pub liked: bool,
    /// Mirror only a playlist, by id or name (repeatable). Resolves against your
    /// own non-trashed playlists. A scoped run never deletes and does not
    /// maintain `.m3u8` playlists.
    #[arg(long, value_name = "ID_OR_NAME")]
    pub playlist: Vec<String>,
    /// Also write an untimed `.lrc` sidecar next to each song (plain lyrics, no
    /// per-line timestamps).
    #[arg(long)]
    pub lrc_sidecar: bool,
}

/// `check` accepts every `sync` flag plus `--exit-code`.
#[derive(Args, Debug, Clone, Default)]
pub struct CheckArgs {
    #[command(flatten)]
    pub sync: SyncArgs,
    /// Exit 1 when changes are pending, 0 when up to date (for CI).
    #[arg(long)]
    pub exit_code: bool,
}

/// Flags shared by `ls` and `lsjson`.
#[derive(Args, Debug, Clone, Default)]
pub struct LsArgs {
    /// List only liked clips.
    #[arg(long)]
    pub liked: bool,
    /// Stop after the first N clips.
    #[arg(long, value_name = "N")]
    pub limit: Option<usize>,
    /// Show clips newer than a relative time (e.g. 7d, 2w, last-run).
    #[arg(long, value_name = "SPEC")]
    pub since: Option<String>,
    /// Output format: text or json.
    #[arg(long, value_enum, value_name = "FORMAT", default_value_t = OutputFormat::Text)]
    pub format: OutputFormat,
}

/// `fetch` arguments.
#[derive(Args, Debug, Clone)]
pub struct FetchArgs {
    /// The clip ID or a Suno URL containing it.
    #[arg(value_name = "ID_OR_URL")]
    pub id: String,
    /// Destination directory or file (defaults to the current directory).
    #[arg(value_name = "DEST")]
    pub dest: Option<PathBuf>,
    /// Audio format: mp3, flac, wav.
    #[arg(long, value_enum, value_name = "FORMAT")]
    pub format: Option<AudioFmt>,
    /// Explicit output file path, overriding DEST and auto-naming.
    #[arg(short = 'o', long, value_name = "PATH")]
    pub output: Option<PathBuf>,
}

/// `config` and its subcommands.
#[derive(Args, Debug)]
pub struct ConfigArgs {
    #[command(subcommand)]
    pub command: ConfigCommand,
}

#[derive(Subcommand, Debug)]
pub enum ConfigCommand {
    /// Interactively create a new config file.
    Init,
    /// Add a new account entry to an existing config file.
    AddAccount(ConfigAddAccountArgs),
    /// Print the current config with tokens redacted.
    Show,
}

#[derive(Args, Debug)]
pub struct ConfigAddAccountArgs {
    /// The account label to add.
    #[arg(value_name = "LABEL")]
    pub label: Option<String>,
    /// Token for the new account (hidden in help).
    #[arg(long, value_name = "TOKEN", hide = true)]
    pub token: Option<String>,
}

/// `auth` and its subcommands.
#[derive(Args, Debug)]
pub struct AuthArgs {
    #[command(subcommand)]
    pub command: AuthCommand,
}

#[derive(Subcommand, Debug)]
pub enum AuthCommand {
    /// Re-authenticate one account by re-minting its JWT.
    Refresh(AuthRefreshArgs),
}

#[derive(Args, Debug)]
pub struct AuthRefreshArgs {
    /// The account label (falls back to --account / --all).
    #[arg(value_name = "ACCOUNT")]
    pub account: Option<String>,
}

/// `completions` arguments.
#[derive(Args, Debug)]
pub struct CompletionsArgs {
    /// The shell to emit a completion script for.
    #[arg(value_name = "SHELL")]
    pub shell: clap_complete::Shell,
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::CommandFactory;

    #[test]
    fn cli_definition_is_valid() {
        Cli::command().debug_assert();
    }

    #[test]
    fn verbosity_combines_counts() {
        let g = GlobalArgs {
            verbose: 2,
            quiet: 0,
            ..Default::default()
        };
        assert_eq!(g.verbosity(), 2);
        let g = GlobalArgs {
            verbose: 0,
            quiet: 2,
            ..Default::default()
        };
        assert_eq!(g.verbosity(), -2);
        let g = GlobalArgs {
            verbose: 1,
            quiet: 1,
            ..Default::default()
        };
        assert_eq!(g.verbosity(), 0);
    }

    #[test]
    fn account_and_all_conflict() {
        let result = Cli::try_parse_from(["suno", "--account", "a", "--all", "ls"]);
        assert!(result.is_err());
    }

    #[test]
    fn sync_parses_dest_and_flags() {
        let cli = Cli::try_parse_from([
            "suno",
            "sync",
            "/music",
            "--format",
            "mp3",
            "--limit",
            "5",
            "--min-newest",
            "0",
        ])
        .unwrap();
        match cli.command {
            Command::Sync(args) => {
                assert_eq!(args.dest.as_deref(), Some(std::path::Path::new("/music")));
                assert_eq!(args.format, Some(AudioFmt::Mp3));
                assert_eq!(args.limit, Some(5));
                assert_eq!(args.min_newest, Some(0));
                assert!(!args.animated_covers);
            }
            _ => panic!("expected sync"),
        }
    }

    #[test]
    fn sync_parses_animated_covers_flag() {
        // Present enables it; absent leaves it off (default false).
        let cli = Cli::try_parse_from(["suno", "sync", "/music", "--animated-covers"]).unwrap();
        match cli.command {
            Command::Sync(args) => assert!(args.animated_covers),
            _ => panic!("expected sync"),
        }
        let cli = Cli::try_parse_from(["suno", "copy", "/music"]).unwrap();
        match cli.command {
            Command::Copy(args) => assert!(!args.animated_covers),
            _ => panic!("expected copy"),
        }
    }

    #[test]
    fn sync_parses_allow_account_change_flag() {
        // Off by default; the flag opts into re-pinning the library owner.
        let cli = Cli::try_parse_from(["suno", "sync", "/music"]).unwrap();
        match cli.command {
            Command::Sync(args) => assert!(!args.allow_account_change),
            _ => panic!("expected sync"),
        }
        let cli =
            Cli::try_parse_from(["suno", "sync", "/music", "--allow-account-change"]).unwrap();
        match cli.command {
            Command::Sync(args) => assert!(args.allow_account_change),
            _ => panic!("expected sync"),
        }
    }

    #[test]
    fn sync_parses_text_sidecar_flags() {
        // Each present flag enables its sidecar; absent leaves both off.
        let cli = Cli::try_parse_from([
            "suno",
            "sync",
            "/music",
            "--details-sidecar",
            "--lyrics-sidecar",
            "--lrc-sidecar",
        ])
        .unwrap();
        match cli.command {
            Command::Sync(args) => {
                assert!(args.details_sidecar);
                assert!(args.lyrics_sidecar);
                assert!(args.lrc_sidecar);
            }
            _ => panic!("expected sync"),
        }
        let cli = Cli::try_parse_from(["suno", "sync", "/music"]).unwrap();
        match cli.command {
            Command::Sync(args) => {
                assert!(!args.details_sidecar);
                assert!(!args.lyrics_sidecar);
                assert!(!args.lrc_sidecar);
            }
            _ => panic!("expected sync"),
        }
    }

    #[test]
    fn sync_parses_scope_flags() {
        // --liked and repeatable --playlist both land on the sync args; absent
        // leaves the scope empty (a full-account run).
        let cli = Cli::try_parse_from([
            "suno",
            "sync",
            "/music",
            "--liked",
            "--playlist",
            "Chill",
            "--playlist",
            "id-42",
        ])
        .unwrap();
        match cli.command {
            Command::Sync(args) => {
                assert!(args.liked);
                assert_eq!(args.playlist, vec!["Chill".to_owned(), "id-42".to_owned()]);
            }
            _ => panic!("expected sync"),
        }
        let cli = Cli::try_parse_from(["suno", "copy", "/music"]).unwrap();
        match cli.command {
            Command::Copy(args) => {
                assert!(!args.liked);
                assert!(args.playlist.is_empty());
            }
            _ => panic!("expected copy"),
        }
    }

    #[test]
    fn check_flattens_scope_flags() {
        let cli =
            Cli::try_parse_from(["suno", "check", "/music", "--liked", "--playlist", "Focus"])
                .unwrap();
        match cli.command {
            Command::Check(args) => {
                assert!(args.sync.liked);
                assert_eq!(args.sync.playlist, vec!["Focus".to_owned()]);
            }
            _ => panic!("expected check"),
        }
    }

    #[test]
    fn global_flags_accepted_after_subcommand() {
        let cli =
            Cli::try_parse_from(["suno", "sync", "/music", "--dry-run", "-vv", "--yes"]).unwrap();
        assert!(cli.global.dry_run);
        assert!(cli.global.yes);
        assert_eq!(cli.global.verbosity(), 2);
    }

    #[test]
    fn check_has_exit_code_flag() {
        let cli = Cli::try_parse_from(["suno", "check", "/music", "--exit-code"]).unwrap();
        match cli.command {
            Command::Check(args) => assert!(args.exit_code),
            _ => panic!("expected check"),
        }
    }

    #[test]
    fn lsjson_and_ls_share_flags() {
        let cli = Cli::try_parse_from(["suno", "lsjson", "--liked", "--limit", "3"]).unwrap();
        match cli.command {
            Command::Lsjson(args) => {
                assert!(args.liked);
                assert_eq!(args.limit, Some(3));
            }
            _ => panic!("expected lsjson"),
        }
    }

    #[test]
    fn completions_parses_shell() {
        let cli = Cli::try_parse_from(["suno", "completions", "bash"]).unwrap();
        assert!(matches!(cli.command, Command::Completions(_)));
    }

    #[test]
    fn audio_fmt_maps_to_core() {
        assert_eq!(AudioFormat::from(AudioFmt::Flac), AudioFormat::Flac);
        assert_eq!(AudioFormat::from(AudioFmt::Mp3), AudioFormat::Mp3);
        assert_eq!(AudioFormat::from(AudioFmt::Wav), AudioFormat::Wav);
    }
}