suno-core 0.36.1

Engine for a download-only Suno.ai library tool: feed selection, sync reconciliation, and audio tagging.
Documentation
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
481
482
483
484
485
//! The domain models the engine works in: the [`Clip`] track and its accessors,
//! plus the [`Playlist`], [`Stem`], and [`BillingInfo`] account types. The JSON
//! decode that builds these from the Suno API shape lives in [`wire`](crate::wire).

use crate::consts::CDN_BASE_URL;
use std::collections::BTreeSet;

/// One finished Suno track, flattened from the API's nested response shape.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Clip {
    pub id: String,
    pub title: String,
    pub audio_url: String,
    /// Every audio asset Suno lists for the clip (an `mp3` plus, usually, an
    /// `m4a-opus`); empty when the API omits `media_urls`. The `mp3` entry is
    /// the authoritative, non-expiring source.
    pub media_urls: Vec<MediaUrl>,
    pub image_url: String,
    pub image_large_url: String,
    pub video_url: String,
    pub video_cover_url: String,
    pub tags: String,
    pub duration: f64,
    pub play_count: u64,
    pub status: String,
    pub created_at: String,
    pub display_name: String,
    pub handle: String,
    /// The clip owner's account id (top-level `user_id`). Feeds the
    /// foreign-owner attribution check and cross-account dedup; empty when the
    /// API omits it.
    pub user_id: String,
    /// Index within a generation batch (paired gens), for sibling
    /// disambiguation in naming and dedup. `None` when `batch_index` is absent.
    pub batch_index: Option<i64>,
    /// The clip owner's avatar image URL (`avatar_image_url`, or the
    /// `user_`-prefixed form on a parent-shaped clip). Empty when absent.
    pub avatar_image_url: String,
    pub is_liked: bool,
    pub is_trashed: bool,
    pub has_vocal: bool,
    /// Whether Suno reports this clip already has separated stems, from
    /// `metadata.has_stem`. The stems mirror uses it as a precondition: a clip
    /// whose `has_stem` is false or absent is never queried for stems.
    pub has_stem: bool,
    /// `metadata.stem_from_id`: the clip this one was separated from, when it is
    /// a stem child. Empty when absent. Structured stem lineage, carried on an
    /// ordinary feed clip independently of the `/stems` listing.
    pub stem_from_id: String,
    /// `metadata.stem_task`: the separation-run id grouping one set of stems.
    /// Empty when absent.
    pub stem_task: String,
    /// `metadata.stem_type_id`: the numeric separation-type id. Tolerates both
    /// the integer and the float (`91.0`) forms Suno has used; `None` when
    /// absent or non-numeric.
    pub stem_type_id: Option<i64>,
    /// `metadata.stem_type_group_name`: the canonical stem group in underscore
    /// form (e.g. `Backing_Vocals`). Empty when absent. Preferred, normalised,
    /// over a title parenthetical as the stem label.
    pub stem_type_group_name: String,
    pub clip_type: String,
    pub prompt: String,
    pub gpt_description_prompt: String,
    pub lyrics: String,
    pub model_name: String,
    pub major_model_version: String,
    pub edited_clip_id: String,
    pub task: String,
    pub is_remix: bool,
    pub cover_clip_id: String,
    pub upsample_clip_id: String,
    pub remaster_clip_id: String,
    pub speed_clip_id: String,
    pub override_history_clip_id: String,
    pub override_future_clip_id: String,
    pub history: Vec<HistoryEntry>,
    pub concat_history: Vec<HistoryEntry>,
    /// The remix/attribution origins Suno lists under the nested `clip_roots`
    /// object (`clip_roots.clips[]`). Empty when the key is absent. These feed
    /// attribution edges and a same-owner gap-fill seed only; they are never
    /// read by structural root resolution.
    pub clip_roots: Vec<ClipRoot>,
    /// The attribution kind for `clip_roots` (`clip_roots.clip_attribution_type`,
    /// e.g. `"remix"`). Open string, empty when absent.
    pub clip_attribution_type: String,
}

/// One remix/attribution origin from a clip's nested `clip_roots.clips[]` list.
///
/// Informational lineage the API exposes directly on the clip: the clip was
/// derived from this root. Identity keys are `user_`-prefixed here. Every field
/// defaults to empty/false when absent, so a reshaped or partial entry degrades
/// rather than fails.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ClipRoot {
    pub id: String,
    pub title: String,
    pub image_url: String,
    pub is_public: bool,
    pub display_name: String,
    pub handle: String,
    pub avatar_image_url: String,
}

/// One audio asset from a clip's top-level `media_urls` list.
///
/// Suno lists each downloadable rendition (an `mp3`, and usually an
/// `m4a-opus`) with its `content_type`, `delivery` mode, and an optional
/// `encoding` version (only the m4a-opus carries one). Every field defaults to
/// empty when absent, so a reshaped or partial entry degrades rather than
/// fails.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct MediaUrl {
    pub url: String,
    pub content_type: String,
    pub delivery: String,
    pub encoding: String,
}

/// One entry in a clip's `history` or `concat_history`, mirroring the API's
/// per-segment lineage record. Ids are stored verbatim (any `m_` prefix is left
/// for the resolver to strip).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct HistoryEntry {
    pub id: String,
    pub infill: bool,
    pub continue_at: Option<f64>,
    pub infill_start_s: Option<f64>,
    pub infill_end_s: Option<f64>,
    pub infill_lyrics: String,
}

impl Clip {
    /// The MP3 source URL, in priority order: the API-listed `media_urls` `mp3`
    /// asset (authoritative and non-expiring), then the clip's `audio_url`, then
    /// the deterministic CDN URL synthesised from the id.
    pub fn mp3_url(&self) -> String {
        if let Some(mp3) = self
            .media_urls
            .iter()
            .find(|media| media.content_type == "mp3" && !media.url.is_empty())
        {
            return cdn_audio_url(&mp3.url, &self.id);
        }
        if self.audio_url.is_empty() {
            format!("{CDN_BASE_URL}/{}.mp3", self.id)
        } else {
            self.audio_url.clone()
        }
    }

    /// Static cover-art image URLs in preference order (large image, then
    /// image), dropping any that are empty.
    ///
    /// The `video_cover_url` preview is deliberately excluded: it is an MP4, not
    /// an embeddable still, so a clip with only a video preview yields no cover
    /// (the animated cover is embedded separately as a transcoded WebP).
    pub fn cover_candidates(&self) -> Vec<&str> {
        [self.image_large_url.as_str(), self.image_url.as_str()]
            .into_iter()
            .filter(|url| !url.is_empty())
            .collect()
    }

    /// The preferred static cover-art image URL, or `None` when the clip carries
    /// no still image.
    ///
    /// Like [`cover_candidates`](Self::cover_candidates), the `video_cover_url`
    /// preview (an MP4) is deliberately excluded: this drives the static `.jpg`
    /// sidecars, the album `folder.jpg`, and the embedded-cover identity hash,
    /// none of which can use a video. A clip with only a video preview yields
    /// `None`.
    pub fn selected_image_url(&self) -> Option<&str> {
        if !self.image_large_url.is_empty() {
            Some(self.image_large_url.as_str())
        } else if !self.image_url.is_empty() {
            Some(self.image_url.as_str())
        } else {
            None
        }
    }
}

/// Rewrite an expiring `audiopipe` audio URL to the permanent CDN URL for `id`.
/// Any other URL, including an empty one, is returned unchanged, and an empty
/// `id` leaves the URL untouched because the CDN URL cannot be synthesised
/// without it. Shared by `audio_url` mapping and `mp3_url` so no single URL
/// source can leak an expiring link.
pub(crate) fn cdn_audio_url(url: &str, id: &str) -> String {
    if url.contains("audiopipe") && !id.is_empty() {
        format!("{CDN_BASE_URL}/{id}.mp3")
    } else {
        url.to_string()
    }
}

/// One of the account's own playlists, as listed by `/api/playlist/me`.
///
/// Carries only what playlist reconciliation needs: the stable id (the state
/// key), the display name (drives the `.m3u8` file name and `#PLAYLIST` line),
/// and the member count for reporting. The ordered members are fetched
/// separately with [`SunoClient::get_playlist_clips`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Playlist {
    /// The playlist's stable Suno id.
    pub id: String,
    /// The playlist's display name.
    pub name: String,
    /// The number of clips Suno reports in the playlist.
    pub num_clips: u64,
}

/// The authenticated account's billing snapshot: credits, quota, account
/// status, plan identity, and entitlements.
///
/// Every field is optional so a drifting payload never fails the parse; an
/// absent field reads as "unknown", not zero. Numbers are signed because the
/// API returns negatives (e.g. the `-1` sentinel), and `features` is a plain
/// string set rather than an enum so new entitlement flags surface without a
/// code change.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BillingInfo {
    /// Credits remaining in the current billing state.
    pub total_credits_left: Option<i64>,
    /// Monthly credit allotment (the quota denominator).
    pub monthly_limit: Option<i64>,
    /// Credits consumed this period (the quota numerator).
    pub monthly_usage: Option<i64>,
    /// Add-on, non-monthly credit balance.
    pub credits: Option<i64>,
    /// Billing period unit, e.g. `"month"`.
    pub period: Option<String>,
    /// Current period end (ISO8601), when usage resets.
    pub period_end: Option<String>,
    /// Next renewal (ISO8601).
    pub renews_on: Option<String>,
    /// Whether the subscription is active.
    pub is_active: Option<bool>,
    /// Whether the subscription is paused (paused subs stop refreshing credits).
    pub is_paused: Option<bool>,
    /// Whether payment is failing (credits may stop refreshing).
    pub is_past_due: Option<bool>,
    /// Whether the subscription is gifted.
    pub is_gifted: Option<bool>,
    /// Subscription platform, e.g. `"stripe"`.
    pub subscription_platform: Option<String>,
    /// Stable machine key for the plan tier, e.g. `"pro"`.
    pub plan_key: Option<String>,
    /// Human plan label, e.g. `"Pro Plan"`.
    pub plan_name: Option<String>,
    /// Plan tier rank (free 0, pro 10, premier 30).
    pub plan_level: Option<i64>,
    /// Entitlement flags, the union of `accessible_features[].name` and
    /// `plan.usage_plan_features[].name`.
    pub features: BTreeSet<String>,
}

impl BillingInfo {
    /// Whether the account is entitled to the named feature.
    pub fn has_feature(&self, name: &str) -> bool {
        self.features.contains(name)
    }

    /// Whether the account may separate stems.
    pub fn can_get_stems(&self) -> bool {
        self.has_feature("get_stems")
    }

    /// Whether the account may convert audio to lossless.
    pub fn can_convert_audio(&self) -> bool {
        self.has_feature("convert_audio")
    }
}

/// One separated stem of a clip, as listed by the free, read-only stems
/// endpoint.
///
/// A stem is itself a full clip object: the listing returns the same shape as
/// the library feed, so each stem carries its own clip `id`, a `title` whose
/// trailing parenthetical is the stem label (e.g. `"My Song (Vocals)"`), a
/// `status`, and a public `audio_url` on `cdn1.suno.ai` that downloads free and
/// unauthenticated. Listing and downloading stems never spends credits or
/// triggers separation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Stem {
    /// The stem's own server clip id. Used both as the stable per-stem key and
    /// to render the stem's lossless WAV through the free `convert_wav` flow.
    pub id: String,
    /// The stem label, preferring the structured `metadata.stem_type_group_name`
    /// (normalised, e.g. `Backing_Vocals` -> `Backing Vocals`) and falling back
    /// to the trailing parenthetical of the stem clip's title. May be blank when
    /// neither is present, so it is never used alone as a key or name.
    pub label: String,
    /// The public CDN MP3 URL the stem downloads from (a plain GET; free).
    pub url: String,
}

/// The stem's label, preferring the structured `metadata.stem_type_group_name`
/// (normalised from its underscore form, `Backing_Vocals` -> `Backing Vocals`)
/// over the fragile trailing title parenthetical, and empty when neither is
/// present so the caller falls back to the stem id for naming.
pub(crate) fn stem_label(clip: &Clip) -> String {
    let group = clip.stem_type_group_name.replace('_', " ");
    let group = group.trim();
    if !group.is_empty() {
        return group.to_string();
    }
    stem_label_from_title(&clip.title)
}

/// The stem label carried in a stem clip's title: the text inside its trailing
/// parenthetical (`"My Song (Backing Vocals)"` -> `Backing Vocals`). Returns an
/// empty string when the title has no closing parenthetical, so the caller falls
/// back to the stem id for naming.
fn stem_label_from_title(title: &str) -> String {
    let trimmed = title.trim_end();
    let Some(before_close) = trimmed.strip_suffix(')') else {
        return String::new();
    };
    match before_close.rfind('(') {
        Some(open) => before_close[open + 1..].trim().to_string(),
        None => String::new(),
    }
}

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

    fn art_clip(image_large: &str, image: &str, video_cover: &str) -> Clip {
        Clip {
            image_large_url: image_large.to_owned(),
            image_url: image.to_owned(),
            video_cover_url: video_cover.to_owned(),
            ..Default::default()
        }
    }

    #[test]
    fn mp3_url_uses_audio_url_or_synthesises_the_cdn_url() {
        let mut clip = Clip {
            id: "z".to_owned(),
            audio_url: "https://x/real.mp3".to_owned(),
            ..Default::default()
        };
        assert_eq!(clip.mp3_url(), "https://x/real.mp3");
        clip.audio_url = String::new();
        assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");
    }

    #[test]
    fn mp3_url_prefers_the_media_urls_mp3_then_audio_url_then_synthesis() {
        // The API-listed mp3 asset wins over audio_url.
        let clip = Clip {
            id: "z".to_owned(),
            audio_url: "https://x/real.mp3".to_owned(),
            media_urls: vec![
                MediaUrl {
                    url: "https://media/z.m4a".to_owned(),
                    content_type: "m4a-opus".to_owned(),
                    delivery: "progressive".to_owned(),
                    encoding: "1.0.0".to_owned(),
                },
                MediaUrl {
                    url: "https://cdn1.suno.ai/z.mp3".to_owned(),
                    content_type: "mp3".to_owned(),
                    delivery: "progressive".to_owned(),
                    encoding: String::new(),
                },
            ],
            ..Default::default()
        };
        assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");

        // Absent media_urls falls back to audio_url unchanged.
        let no_media = Clip {
            id: "z".to_owned(),
            audio_url: "https://x/real.mp3".to_owned(),
            ..Default::default()
        };
        assert_eq!(no_media.mp3_url(), "https://x/real.mp3");

        // A media_urls set with only a non-mp3 asset still falls back.
        let only_m4a = Clip {
            id: "z".to_owned(),
            audio_url: String::new(),
            media_urls: vec![MediaUrl {
                url: "https://media/z.m4a".to_owned(),
                content_type: "m4a-opus".to_owned(),
                ..Default::default()
            }],
            ..Default::default()
        };
        assert_eq!(only_m4a.mp3_url(), "https://cdn1.suno.ai/z.mp3");
    }

    #[test]
    fn mp3_url_rewrites_an_expiring_audiopipe_media_url() {
        // An audiopipe mp3 in media_urls expires, so mp3_url rewrites it to the
        // permanent CDN URL, matching how audio_url is rewritten at parse time.
        let expiring = Clip {
            id: "z".to_owned(),
            media_urls: vec![MediaUrl {
                url: "https://audiopipe.suno.ai/item?id=z".to_owned(),
                content_type: "mp3".to_owned(),
                ..Default::default()
            }],
            ..Default::default()
        };
        assert_eq!(expiring.mp3_url(), "https://cdn1.suno.ai/z.mp3");

        // A permanent (non-audiopipe) mp3 asset is returned verbatim.
        let permanent = Clip {
            id: "z".to_owned(),
            media_urls: vec![MediaUrl {
                url: "https://cdn1.suno.ai/z.mp3".to_owned(),
                content_type: "mp3".to_owned(),
                ..Default::default()
            }],
            ..Default::default()
        };
        assert_eq!(permanent.mp3_url(), "https://cdn1.suno.ai/z.mp3");
    }

    #[test]
    fn cover_candidates_are_static_images_ordered_and_filtered() {
        // The video preview (an MP4) is never an embeddable still, so it is
        // excluded; only the static image URLs remain, in preference order.
        assert_eq!(art_clip("L", "I", "V").cover_candidates(), vec!["L", "I"]);
        assert_eq!(art_clip("L", "", "V").cover_candidates(), vec!["L"]);
        assert!(art_clip("", "", "V").cover_candidates().is_empty());
    }

    #[test]
    fn selected_image_url_prefers_large_then_image_and_excludes_video() {
        assert_eq!(art_clip("L", "I", "V").selected_image_url(), Some("L"));
        assert_eq!(art_clip("", "I", "V").selected_image_url(), Some("I"));
        // A video-only clip has no still image to embed or write as a `.jpg`.
        assert_eq!(art_clip("", "", "V").selected_image_url(), None);
        assert_eq!(art_clip("", "", "").selected_image_url(), None);
    }
    #[test]
    fn stem_label_prefers_the_normalised_group_over_the_title() {
        // The structured group name wins and its underscore form is normalised.
        let grouped = Clip {
            title: "Track 30".to_owned(),
            stem_type_group_name: "Backing_Vocals".to_owned(),
            ..Default::default()
        };
        assert_eq!(stem_label(&grouped), "Backing Vocals");
        // It still wins over a present title parenthetical (strictly more
        // reliable and language-stable than title scraping).
        let both = Clip {
            title: "My Song (Guitar)".to_owned(),
            stem_type_group_name: "Vocals".to_owned(),
            ..Default::default()
        };
        assert_eq!(stem_label(&both), "Vocals");
        // No group name: fall back to the title parenthetical.
        let titled = Clip {
            title: "My Song (Drums)".to_owned(),
            ..Default::default()
        };
        assert_eq!(stem_label(&titled), "Drums");
        // Neither present: empty, so the caller falls back to the stem id.
        let bare = Clip {
            title: "Track 31".to_owned(),
            ..Default::default()
        };
        assert_eq!(stem_label(&bare), "");
    }

    #[test]
    fn stem_label_from_title_extracts_trailing_parenthetical() {
        assert_eq!(stem_label_from_title("My Song (Vocals)"), "Vocals");
        assert_eq!(
            stem_label_from_title("A (b) Song (Backing Vocals)"),
            "Backing Vocals"
        );
        assert_eq!(stem_label_from_title("My Song (Drums) "), "Drums");
        // No parenthetical: empty, so the caller falls back to the stem id.
        assert_eq!(stem_label_from_title("My Song"), "");
        assert_eq!(stem_label_from_title(""), "");
    }
}