suno_core/model.rs
1//! The domain models the engine works in: the [`Clip`] track and its accessors,
2//! plus the [`Playlist`], [`Stem`], and [`BillingInfo`] account types. The JSON
3//! decode that builds these from the Suno API shape lives in [`wire`](crate::wire).
4
5use crate::consts::CDN_BASE_URL;
6use std::collections::BTreeSet;
7
8/// One finished Suno track, flattened from the API's nested response shape.
9#[derive(Debug, Clone, Default, PartialEq)]
10pub struct Clip {
11 pub id: String,
12 pub title: String,
13 pub audio_url: String,
14 /// Every audio asset Suno lists for the clip (an `mp3` plus, usually, an
15 /// `m4a-opus`); empty when the API omits `media_urls`. The `mp3` entry is
16 /// the authoritative, non-expiring source.
17 pub media_urls: Vec<MediaUrl>,
18 pub image_url: String,
19 pub image_large_url: String,
20 pub video_url: String,
21 pub video_cover_url: String,
22 pub tags: String,
23 pub duration: f64,
24 pub play_count: u64,
25 pub status: String,
26 pub created_at: String,
27 pub display_name: String,
28 pub handle: String,
29 /// The clip owner's account id (top-level `user_id`). Feeds the
30 /// foreign-owner attribution check and cross-account dedup; empty when the
31 /// API omits it.
32 pub user_id: String,
33 /// Index within a generation batch (paired gens), for sibling
34 /// disambiguation in naming and dedup. `None` when `batch_index` is absent.
35 pub batch_index: Option<i64>,
36 /// The clip owner's avatar image URL (`avatar_image_url`, or the
37 /// `user_`-prefixed form on a parent-shaped clip). Empty when absent.
38 pub avatar_image_url: String,
39 pub is_liked: bool,
40 pub is_trashed: bool,
41 pub has_vocal: bool,
42 /// Whether Suno reports this clip already has separated stems, from
43 /// `metadata.has_stem`. The stems mirror uses it as a precondition: a clip
44 /// whose `has_stem` is false or absent is never queried for stems.
45 pub has_stem: bool,
46 /// `metadata.stem_from_id`: the clip this one was separated from, when it is
47 /// a stem child. Empty when absent. Structured stem lineage, carried on an
48 /// ordinary feed clip independently of the `/stems` listing.
49 pub stem_from_id: String,
50 /// `metadata.stem_task`: the separation-run id grouping one set of stems.
51 /// Empty when absent.
52 pub stem_task: String,
53 /// `metadata.stem_type_id`: the numeric separation-type id. Tolerates both
54 /// the integer and the float (`91.0`) forms Suno has used; `None` when
55 /// absent or non-numeric.
56 pub stem_type_id: Option<i64>,
57 /// `metadata.stem_type_group_name`: the canonical stem group in underscore
58 /// form (e.g. `Backing_Vocals`). Empty when absent. Preferred, normalised,
59 /// over a title parenthetical as the stem label.
60 pub stem_type_group_name: String,
61 pub clip_type: String,
62 pub prompt: String,
63 pub gpt_description_prompt: String,
64 pub lyrics: String,
65 pub model_name: String,
66 pub major_model_version: String,
67 pub edited_clip_id: String,
68 pub task: String,
69 pub is_remix: bool,
70 pub cover_clip_id: String,
71 pub upsample_clip_id: String,
72 pub remaster_clip_id: String,
73 pub speed_clip_id: String,
74 pub override_history_clip_id: String,
75 pub override_future_clip_id: String,
76 pub history: Vec<HistoryEntry>,
77 pub concat_history: Vec<HistoryEntry>,
78 /// The remix/attribution origins Suno lists under the nested `clip_roots`
79 /// object (`clip_roots.clips[]`). Empty when the key is absent. These feed
80 /// attribution edges and a same-owner gap-fill seed only; they are never
81 /// read by structural root resolution.
82 pub clip_roots: Vec<ClipRoot>,
83 /// The attribution kind for `clip_roots` (`clip_roots.clip_attribution_type`,
84 /// e.g. `"remix"`). Open string, empty when absent.
85 pub clip_attribution_type: String,
86}
87
88/// One remix/attribution origin from a clip's nested `clip_roots.clips[]` list.
89///
90/// Informational lineage the API exposes directly on the clip: the clip was
91/// derived from this root. Identity keys are `user_`-prefixed here. Every field
92/// defaults to empty/false when absent, so a reshaped or partial entry degrades
93/// rather than fails.
94#[derive(Debug, Clone, Default, PartialEq)]
95pub struct ClipRoot {
96 pub id: String,
97 pub title: String,
98 pub image_url: String,
99 pub is_public: bool,
100 pub display_name: String,
101 pub handle: String,
102 pub avatar_image_url: String,
103}
104
105/// One audio asset from a clip's top-level `media_urls` list.
106///
107/// Suno lists each downloadable rendition (an `mp3`, and usually an
108/// `m4a-opus`) with its `content_type`, `delivery` mode, and an optional
109/// `encoding` version (only the m4a-opus carries one). Every field defaults to
110/// empty when absent, so a reshaped or partial entry degrades rather than
111/// fails.
112#[derive(Debug, Clone, Default, PartialEq)]
113pub struct MediaUrl {
114 pub url: String,
115 pub content_type: String,
116 pub delivery: String,
117 pub encoding: String,
118}
119
120/// One entry in a clip's `history` or `concat_history`, mirroring the API's
121/// per-segment lineage record. Ids are stored verbatim (any `m_` prefix is left
122/// for the resolver to strip).
123#[derive(Debug, Clone, Default, PartialEq)]
124pub struct HistoryEntry {
125 pub id: String,
126 pub infill: bool,
127 pub continue_at: Option<f64>,
128 pub infill_start_s: Option<f64>,
129 pub infill_end_s: Option<f64>,
130 pub infill_lyrics: String,
131}
132
133impl Clip {
134 /// The MP3 source URL, in priority order: the API-listed `media_urls` `mp3`
135 /// asset (authoritative and non-expiring), then the clip's `audio_url`, then
136 /// the deterministic CDN URL synthesised from the id.
137 pub fn mp3_url(&self) -> String {
138 if let Some(mp3) = self
139 .media_urls
140 .iter()
141 .find(|media| media.content_type == "mp3" && !media.url.is_empty())
142 {
143 return cdn_audio_url(&mp3.url, &self.id);
144 }
145 if self.audio_url.is_empty() {
146 format!("{CDN_BASE_URL}/{}.mp3", self.id)
147 } else {
148 self.audio_url.clone()
149 }
150 }
151
152 /// Static cover-art image URLs in preference order (large image, then
153 /// image), dropping any that are empty.
154 ///
155 /// The `video_cover_url` preview is deliberately excluded: it is an MP4, not
156 /// an embeddable still, so a clip with only a video preview yields no cover
157 /// (the animated cover is embedded separately as a transcoded WebP).
158 pub fn cover_candidates(&self) -> Vec<&str> {
159 [self.image_large_url.as_str(), self.image_url.as_str()]
160 .into_iter()
161 .filter(|url| !url.is_empty())
162 .collect()
163 }
164
165 /// The preferred static cover-art image URL, or `None` when the clip carries
166 /// no still image.
167 ///
168 /// Like [`cover_candidates`](Self::cover_candidates), the `video_cover_url`
169 /// preview (an MP4) is deliberately excluded: this drives the static `.jpg`
170 /// sidecars, the album `folder.jpg`, and the embedded-cover identity hash,
171 /// none of which can use a video. A clip with only a video preview yields
172 /// `None`.
173 pub fn selected_image_url(&self) -> Option<&str> {
174 if !self.image_large_url.is_empty() {
175 Some(self.image_large_url.as_str())
176 } else if !self.image_url.is_empty() {
177 Some(self.image_url.as_str())
178 } else {
179 None
180 }
181 }
182}
183
184/// Rewrite an expiring `audiopipe` audio URL to the permanent CDN URL for `id`.
185/// Any other URL, including an empty one, is returned unchanged, and an empty
186/// `id` leaves the URL untouched because the CDN URL cannot be synthesised
187/// without it. Shared by `audio_url` mapping and `mp3_url` so no single URL
188/// source can leak an expiring link.
189pub(crate) fn cdn_audio_url(url: &str, id: &str) -> String {
190 if url.contains("audiopipe") && !id.is_empty() {
191 format!("{CDN_BASE_URL}/{id}.mp3")
192 } else {
193 url.to_string()
194 }
195}
196
197/// One of the account's own playlists, as listed by `/api/playlist/me`.
198///
199/// Carries only what playlist reconciliation needs: the stable id (the state
200/// key), the display name (drives the `.m3u8` file name and `#PLAYLIST` line),
201/// and the member count for reporting. The ordered members are fetched
202/// separately with [`SunoClient::get_playlist_clips`].
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct Playlist {
205 /// The playlist's stable Suno id.
206 pub id: String,
207 /// The playlist's display name.
208 pub name: String,
209 /// The number of clips Suno reports in the playlist.
210 pub num_clips: u64,
211}
212
213/// The authenticated account's billing snapshot: credits, quota, account
214/// status, plan identity, and entitlements.
215///
216/// Every field is optional so a drifting payload never fails the parse; an
217/// absent field reads as "unknown", not zero. Numbers are signed because the
218/// API returns negatives (e.g. the `-1` sentinel), and `features` is a plain
219/// string set rather than an enum so new entitlement flags surface without a
220/// code change.
221#[derive(Debug, Clone, Default, PartialEq, Eq)]
222pub struct BillingInfo {
223 /// Credits remaining in the current billing state.
224 pub total_credits_left: Option<i64>,
225 /// Monthly credit allotment (the quota denominator).
226 pub monthly_limit: Option<i64>,
227 /// Credits consumed this period (the quota numerator).
228 pub monthly_usage: Option<i64>,
229 /// Add-on, non-monthly credit balance.
230 pub credits: Option<i64>,
231 /// Billing period unit, e.g. `"month"`.
232 pub period: Option<String>,
233 /// Current period end (ISO8601), when usage resets.
234 pub period_end: Option<String>,
235 /// Next renewal (ISO8601).
236 pub renews_on: Option<String>,
237 /// Whether the subscription is active.
238 pub is_active: Option<bool>,
239 /// Whether the subscription is paused (paused subs stop refreshing credits).
240 pub is_paused: Option<bool>,
241 /// Whether payment is failing (credits may stop refreshing).
242 pub is_past_due: Option<bool>,
243 /// Whether the subscription is gifted.
244 pub is_gifted: Option<bool>,
245 /// Subscription platform, e.g. `"stripe"`.
246 pub subscription_platform: Option<String>,
247 /// Stable machine key for the plan tier, e.g. `"pro"`.
248 pub plan_key: Option<String>,
249 /// Human plan label, e.g. `"Pro Plan"`.
250 pub plan_name: Option<String>,
251 /// Plan tier rank (free 0, pro 10, premier 30).
252 pub plan_level: Option<i64>,
253 /// Entitlement flags, the union of `accessible_features[].name` and
254 /// `plan.usage_plan_features[].name`.
255 pub features: BTreeSet<String>,
256}
257
258impl BillingInfo {
259 /// Whether the account is entitled to the named feature.
260 pub fn has_feature(&self, name: &str) -> bool {
261 self.features.contains(name)
262 }
263
264 /// Whether the account may separate stems.
265 pub fn can_get_stems(&self) -> bool {
266 self.has_feature("get_stems")
267 }
268
269 /// Whether the account may convert audio to lossless.
270 pub fn can_convert_audio(&self) -> bool {
271 self.has_feature("convert_audio")
272 }
273}
274
275/// One separated stem of a clip, as listed by the free, read-only stems
276/// endpoint.
277///
278/// A stem is itself a full clip object: the listing returns the same shape as
279/// the library feed, so each stem carries its own clip `id`, a `title` whose
280/// trailing parenthetical is the stem label (e.g. `"My Song (Vocals)"`), a
281/// `status`, and a public `audio_url` on `cdn1.suno.ai` that downloads free and
282/// unauthenticated. Listing and downloading stems never spends credits or
283/// triggers separation.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct Stem {
286 /// The stem's own server clip id. Used both as the stable per-stem key and
287 /// to render the stem's lossless WAV through the free `convert_wav` flow.
288 pub id: String,
289 /// The stem label, preferring the structured `metadata.stem_type_group_name`
290 /// (normalised, e.g. `Backing_Vocals` -> `Backing Vocals`) and falling back
291 /// to the trailing parenthetical of the stem clip's title. May be blank when
292 /// neither is present, so it is never used alone as a key or name.
293 pub label: String,
294 /// The public CDN MP3 URL the stem downloads from (a plain GET; free).
295 pub url: String,
296}
297
298/// The stem's label, preferring the structured `metadata.stem_type_group_name`
299/// (normalised from its underscore form, `Backing_Vocals` -> `Backing Vocals`)
300/// over the fragile trailing title parenthetical, and empty when neither is
301/// present so the caller falls back to the stem id for naming.
302pub(crate) fn stem_label(clip: &Clip) -> String {
303 let group = clip.stem_type_group_name.replace('_', " ");
304 let group = group.trim();
305 if !group.is_empty() {
306 return group.to_string();
307 }
308 stem_label_from_title(&clip.title)
309}
310
311/// The stem label carried in a stem clip's title: the text inside its trailing
312/// parenthetical (`"My Song (Backing Vocals)"` -> `Backing Vocals`). Returns an
313/// empty string when the title has no closing parenthetical, so the caller falls
314/// back to the stem id for naming.
315fn stem_label_from_title(title: &str) -> String {
316 let trimmed = title.trim_end();
317 let Some(before_close) = trimmed.strip_suffix(')') else {
318 return String::new();
319 };
320 match before_close.rfind('(') {
321 Some(open) => before_close[open + 1..].trim().to_string(),
322 None => String::new(),
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 fn art_clip(image_large: &str, image: &str, video_cover: &str) -> Clip {
331 Clip {
332 image_large_url: image_large.to_owned(),
333 image_url: image.to_owned(),
334 video_cover_url: video_cover.to_owned(),
335 ..Default::default()
336 }
337 }
338
339 #[test]
340 fn mp3_url_uses_audio_url_or_synthesises_the_cdn_url() {
341 let mut clip = Clip {
342 id: "z".to_owned(),
343 audio_url: "https://x/real.mp3".to_owned(),
344 ..Default::default()
345 };
346 assert_eq!(clip.mp3_url(), "https://x/real.mp3");
347 clip.audio_url = String::new();
348 assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");
349 }
350
351 #[test]
352 fn mp3_url_prefers_the_media_urls_mp3_then_audio_url_then_synthesis() {
353 // The API-listed mp3 asset wins over audio_url.
354 let clip = Clip {
355 id: "z".to_owned(),
356 audio_url: "https://x/real.mp3".to_owned(),
357 media_urls: vec![
358 MediaUrl {
359 url: "https://media/z.m4a".to_owned(),
360 content_type: "m4a-opus".to_owned(),
361 delivery: "progressive".to_owned(),
362 encoding: "1.0.0".to_owned(),
363 },
364 MediaUrl {
365 url: "https://cdn1.suno.ai/z.mp3".to_owned(),
366 content_type: "mp3".to_owned(),
367 delivery: "progressive".to_owned(),
368 encoding: String::new(),
369 },
370 ],
371 ..Default::default()
372 };
373 assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");
374
375 // Absent media_urls falls back to audio_url unchanged.
376 let no_media = Clip {
377 id: "z".to_owned(),
378 audio_url: "https://x/real.mp3".to_owned(),
379 ..Default::default()
380 };
381 assert_eq!(no_media.mp3_url(), "https://x/real.mp3");
382
383 // A media_urls set with only a non-mp3 asset still falls back.
384 let only_m4a = Clip {
385 id: "z".to_owned(),
386 audio_url: String::new(),
387 media_urls: vec![MediaUrl {
388 url: "https://media/z.m4a".to_owned(),
389 content_type: "m4a-opus".to_owned(),
390 ..Default::default()
391 }],
392 ..Default::default()
393 };
394 assert_eq!(only_m4a.mp3_url(), "https://cdn1.suno.ai/z.mp3");
395 }
396
397 #[test]
398 fn mp3_url_rewrites_an_expiring_audiopipe_media_url() {
399 // An audiopipe mp3 in media_urls expires, so mp3_url rewrites it to the
400 // permanent CDN URL, matching how audio_url is rewritten at parse time.
401 let expiring = Clip {
402 id: "z".to_owned(),
403 media_urls: vec![MediaUrl {
404 url: "https://audiopipe.suno.ai/item?id=z".to_owned(),
405 content_type: "mp3".to_owned(),
406 ..Default::default()
407 }],
408 ..Default::default()
409 };
410 assert_eq!(expiring.mp3_url(), "https://cdn1.suno.ai/z.mp3");
411
412 // A permanent (non-audiopipe) mp3 asset is returned verbatim.
413 let permanent = Clip {
414 id: "z".to_owned(),
415 media_urls: vec![MediaUrl {
416 url: "https://cdn1.suno.ai/z.mp3".to_owned(),
417 content_type: "mp3".to_owned(),
418 ..Default::default()
419 }],
420 ..Default::default()
421 };
422 assert_eq!(permanent.mp3_url(), "https://cdn1.suno.ai/z.mp3");
423 }
424
425 #[test]
426 fn cover_candidates_are_static_images_ordered_and_filtered() {
427 // The video preview (an MP4) is never an embeddable still, so it is
428 // excluded; only the static image URLs remain, in preference order.
429 assert_eq!(art_clip("L", "I", "V").cover_candidates(), vec!["L", "I"]);
430 assert_eq!(art_clip("L", "", "V").cover_candidates(), vec!["L"]);
431 assert!(art_clip("", "", "V").cover_candidates().is_empty());
432 }
433
434 #[test]
435 fn selected_image_url_prefers_large_then_image_and_excludes_video() {
436 assert_eq!(art_clip("L", "I", "V").selected_image_url(), Some("L"));
437 assert_eq!(art_clip("", "I", "V").selected_image_url(), Some("I"));
438 // A video-only clip has no still image to embed or write as a `.jpg`.
439 assert_eq!(art_clip("", "", "V").selected_image_url(), None);
440 assert_eq!(art_clip("", "", "").selected_image_url(), None);
441 }
442 #[test]
443 fn stem_label_prefers_the_normalised_group_over_the_title() {
444 // The structured group name wins and its underscore form is normalised.
445 let grouped = Clip {
446 title: "Track 30".to_owned(),
447 stem_type_group_name: "Backing_Vocals".to_owned(),
448 ..Default::default()
449 };
450 assert_eq!(stem_label(&grouped), "Backing Vocals");
451 // It still wins over a present title parenthetical (strictly more
452 // reliable and language-stable than title scraping).
453 let both = Clip {
454 title: "My Song (Guitar)".to_owned(),
455 stem_type_group_name: "Vocals".to_owned(),
456 ..Default::default()
457 };
458 assert_eq!(stem_label(&both), "Vocals");
459 // No group name: fall back to the title parenthetical.
460 let titled = Clip {
461 title: "My Song (Drums)".to_owned(),
462 ..Default::default()
463 };
464 assert_eq!(stem_label(&titled), "Drums");
465 // Neither present: empty, so the caller falls back to the stem id.
466 let bare = Clip {
467 title: "Track 31".to_owned(),
468 ..Default::default()
469 };
470 assert_eq!(stem_label(&bare), "");
471 }
472
473 #[test]
474 fn stem_label_from_title_extracts_trailing_parenthetical() {
475 assert_eq!(stem_label_from_title("My Song (Vocals)"), "Vocals");
476 assert_eq!(
477 stem_label_from_title("A (b) Song (Backing Vocals)"),
478 "Backing Vocals"
479 );
480 assert_eq!(stem_label_from_title("My Song (Drums) "), "Drums");
481 // No parenthetical: empty, so the caller falls back to the stem id.
482 assert_eq!(stem_label_from_title("My Song"), "");
483 assert_eq!(stem_label_from_title(""), "");
484 }
485}