Skip to main content

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}