Skip to main content

suno_core/
model.rs

1//! The [`Clip`] domain model and its mapping from the Suno API JSON shape.
2
3use serde_json::Value;
4
5use crate::consts::CDN_BASE_URL;
6
7/// One finished Suno track, flattened from the API's nested response shape.
8#[derive(Debug, Clone, Default, PartialEq)]
9pub struct Clip {
10    pub id: String,
11    pub title: String,
12    pub audio_url: String,
13    pub image_url: String,
14    pub image_large_url: String,
15    pub video_url: String,
16    pub video_cover_url: String,
17    pub tags: String,
18    pub duration: f64,
19    pub play_count: u64,
20    pub status: String,
21    pub created_at: String,
22    pub display_name: String,
23    pub handle: String,
24    pub is_liked: bool,
25    pub is_trashed: bool,
26    pub has_vocal: bool,
27    /// Whether Suno reports this clip already has separated stems, from
28    /// `metadata.has_stem`. The stems mirror uses it as a precondition: a clip
29    /// whose `has_stem` is false or absent is never queried for stems.
30    pub has_stem: bool,
31    pub clip_type: String,
32    pub prompt: String,
33    pub gpt_description_prompt: String,
34    pub lyrics: String,
35    pub model_name: String,
36    pub major_model_version: String,
37    pub album_title: String,
38    pub root_ancestor_id: String,
39    pub lineage_status: String,
40    pub edited_clip_id: String,
41    pub task: String,
42    pub is_remix: bool,
43    pub cover_clip_id: String,
44    pub upsample_clip_id: String,
45    pub remaster_clip_id: String,
46    pub speed_clip_id: String,
47    pub override_history_clip_id: String,
48    pub override_future_clip_id: String,
49    pub history: Vec<HistoryEntry>,
50    pub concat_history: Vec<HistoryEntry>,
51}
52
53/// One entry in a clip's `history` or `concat_history`, mirroring the API's
54/// per-segment lineage record. Ids are stored verbatim (any `m_` prefix is left
55/// for the resolver to strip).
56#[derive(Debug, Clone, Default, PartialEq)]
57pub struct HistoryEntry {
58    pub id: String,
59    pub infill: bool,
60    pub continue_at: Option<f64>,
61    pub infill_start_s: Option<f64>,
62    pub infill_end_s: Option<f64>,
63    pub infill_lyrics: String,
64}
65
66impl Clip {
67    /// Build a [`Clip`] from one raw API clip object.
68    ///
69    /// Clip-level fields and lineage live at the top level; content fields like
70    /// tags and duration live under `metadata`. Temporary `audiopipe` audio URLs
71    /// expire, so they are rewritten to the permanent CDN URL.
72    pub fn from_json(raw: &Value) -> Clip {
73        let metadata = raw.get("metadata").cloned().unwrap_or(Value::Null);
74        let id = string(raw, "id");
75
76        let mut audio_url = string(raw, "audio_url");
77        if audio_url.contains("audiopipe") && !id.is_empty() {
78            audio_url = format!("{CDN_BASE_URL}/{id}.mp3");
79        }
80
81        let title = match raw.get("title") {
82            Some(Value::String(title)) => title.clone(),
83            _ => "Untitled".to_string(),
84        };
85
86        Clip {
87            id,
88            title,
89            audio_url,
90            image_url: cdn(raw, "image_url"),
91            image_large_url: cdn(raw, "image_large_url"),
92            video_url: cdn(raw, "video_url"),
93            video_cover_url: cdn(raw, "video_cover_url"),
94            tags: string(&metadata, "tags"),
95            duration: metadata
96                .get("duration")
97                .and_then(Value::as_f64)
98                .unwrap_or(0.0),
99            play_count: raw.get("play_count").and_then(Value::as_u64).unwrap_or(0),
100            status: raw
101                .get("status")
102                .and_then(Value::as_str)
103                .unwrap_or("unknown")
104                .to_string(),
105            created_at: string(raw, "created_at"),
106            display_name: string(raw, "display_name"),
107            handle: string(raw, "handle"),
108            is_liked: bool_field(raw, "is_liked"),
109            is_trashed: bool_field(raw, "is_trashed"),
110            has_vocal: bool_field(&metadata, "has_vocal"),
111            has_stem: bool_field(&metadata, "has_stem"),
112            clip_type: string(&metadata, "type"),
113            prompt: string(&metadata, "prompt"),
114            gpt_description_prompt: string(&metadata, "gpt_description_prompt"),
115            lyrics: string(raw, "lyrics"),
116            model_name: string(raw, "model_name"),
117            major_model_version: string(raw, "major_model_version"),
118            album_title: string(raw, "album_title"),
119            root_ancestor_id: string(raw, "root_ancestor_id"),
120            lineage_status: string(raw, "lineage_status"),
121            edited_clip_id: string(&metadata, "edited_clip_id"),
122            task: string(&metadata, "task"),
123            is_remix: bool_field(&metadata, "is_remix"),
124            cover_clip_id: string(&metadata, "cover_clip_id"),
125            upsample_clip_id: string(&metadata, "upsample_clip_id"),
126            remaster_clip_id: string(&metadata, "remaster_clip_id"),
127            speed_clip_id: string(&metadata, "speed_clip_id"),
128            override_history_clip_id: string(&metadata, "override_history_clip_id"),
129            override_future_clip_id: string(&metadata, "override_future_clip_id"),
130            history: history_entries(&metadata, "history"),
131            concat_history: history_entries(&metadata, "concat_history"),
132        }
133    }
134
135    /// The MP3 source URL: the clip's `audio_url`, or the deterministic CDN URL
136    /// when it is empty.
137    pub fn mp3_url(&self) -> String {
138        if self.audio_url.is_empty() {
139            format!("{CDN_BASE_URL}/{}.mp3", self.id)
140        } else {
141            self.audio_url.clone()
142        }
143    }
144
145    /// Cover-art URLs in preference order (large image, image, video cover),
146    /// dropping any that are empty.
147    pub fn cover_candidates(&self) -> Vec<&str> {
148        [
149            self.image_large_url.as_str(),
150            self.image_url.as_str(),
151            self.video_cover_url.as_str(),
152        ]
153        .into_iter()
154        .filter(|url| !url.is_empty())
155        .collect()
156    }
157
158    /// The preferred cover-art URL, or `None` when the clip carries no art.
159    pub fn selected_image_url(&self) -> Option<&str> {
160        if !self.image_large_url.is_empty() {
161            Some(self.image_large_url.as_str())
162        } else if !self.image_url.is_empty() {
163            Some(self.image_url.as_str())
164        } else if !self.video_cover_url.is_empty() {
165            Some(self.video_cover_url.as_str())
166        } else {
167            None
168        }
169    }
170}
171
172/// Read a string field, defaulting to empty when missing or not a string.
173fn string(value: &Value, key: &str) -> String {
174    value
175        .get(key)
176        .and_then(Value::as_str)
177        .unwrap_or("")
178        .to_string()
179}
180
181/// Read a bool field, defaulting to `false` when missing or not a bool.
182fn bool_field(value: &Value, key: &str) -> bool {
183    value.get(key).and_then(Value::as_bool).unwrap_or(false)
184}
185
186/// Read a CDN URL field, rewriting the unreliable `cdn2` host to `cdn1`.
187fn cdn(value: &Value, key: &str) -> String {
188    string(value, key).replace("cdn2.suno.ai", "cdn1.suno.ai")
189}
190
191/// Read `value[key]` as an array of history records into [`HistoryEntry`]s.
192///
193/// Each element is mapped verbatim: a bare JSON string becomes an entry with
194/// only its `id` set, while an object supplies `id`, `infill`, `continue_at`,
195/// `infill_start_s`, `infill_end_s`, and `infill_lyrics`. Anything else (a
196/// missing key, a non-array, or an unexpected element type) yields an empty
197/// `Vec` or a defaulted entry, so parsing never fails.
198fn history_entries(value: &Value, key: &str) -> Vec<HistoryEntry> {
199    let Some(Value::Array(items)) = value.get(key) else {
200        return Vec::new();
201    };
202    items
203        .iter()
204        .map(|item| match item {
205            Value::String(id) => HistoryEntry {
206                id: id.clone(),
207                ..HistoryEntry::default()
208            },
209            _ => HistoryEntry {
210                id: string(item, "id"),
211                infill: bool_field(item, "infill"),
212                continue_at: item.get("continue_at").and_then(Value::as_f64),
213                infill_start_s: item.get("infill_start_s").and_then(Value::as_f64),
214                infill_end_s: item.get("infill_end_s").and_then(Value::as_f64),
215                infill_lyrics: string(item, "infill_lyrics"),
216            },
217        })
218        .collect()
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    fn art_clip(image_large: &str, image: &str, video_cover: &str) -> Clip {
226        Clip {
227            image_large_url: image_large.to_owned(),
228            image_url: image.to_owned(),
229            video_cover_url: video_cover.to_owned(),
230            ..Default::default()
231        }
232    }
233
234    #[test]
235    fn mp3_url_uses_audio_url_or_synthesises_the_cdn_url() {
236        let mut clip = Clip {
237            id: "z".to_owned(),
238            audio_url: "https://x/real.mp3".to_owned(),
239            ..Default::default()
240        };
241        assert_eq!(clip.mp3_url(), "https://x/real.mp3");
242        clip.audio_url = String::new();
243        assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");
244    }
245
246    #[test]
247    fn cover_candidates_are_ordered_and_filtered() {
248        let clip = art_clip("L", "", "V");
249        assert_eq!(clip.cover_candidates(), vec!["L", "V"]);
250    }
251
252    #[test]
253    fn selected_image_url_prefers_large_then_image_then_video() {
254        assert_eq!(art_clip("L", "I", "V").selected_image_url(), Some("L"));
255        assert_eq!(art_clip("", "I", "V").selected_image_url(), Some("I"));
256        assert_eq!(art_clip("", "", "V").selected_image_url(), Some("V"));
257        assert_eq!(art_clip("", "", "").selected_image_url(), None);
258    }
259
260    #[test]
261    fn from_json_parses_all_lineage_metadata_fields() {
262        let raw = serde_json::json!({
263            "id": "self",
264            "title": "Lineage",
265            "is_trashed": true,
266            "metadata": {
267                "task": "extend",
268                "is_remix": true,
269                "cover_clip_id": "cover-1",
270                "upsample_clip_id": "upsample-2",
271                "remaster_clip_id": "remaster-3",
272                "speed_clip_id": "speed-4",
273                "override_history_clip_id": "ovh-5",
274                "override_future_clip_id": "ovf-6",
275                "history": [
276                    {
277                        "infill": false,
278                        "id": "0a3c311a-hist",
279                        "source": "ios",
280                        "type": "gen",
281                        "continue_at": 115.35
282                    },
283                    {
284                        "infill": true,
285                        "id": "infill-hist",
286                        "source": "web",
287                        "type": "gen",
288                        "infill_start_s": 12.0,
289                        "infill_end_s": 28.5,
290                        "infill_lyrics": "new words here"
291                    }
292                ],
293                "concat_history": [
294                    {"infill": false, "id": "122d0d15-base", "continue_at": 131.5},
295                    {"id": "cf7cb30f-part"}
296                ]
297            }
298        });
299
300        let clip = Clip::from_json(&raw);
301
302        assert_eq!(clip.task, "extend");
303        assert!(clip.is_remix);
304        assert!(clip.is_trashed);
305        assert_eq!(clip.cover_clip_id, "cover-1");
306        assert_eq!(clip.upsample_clip_id, "upsample-2");
307        assert_eq!(clip.remaster_clip_id, "remaster-3");
308        assert_eq!(clip.speed_clip_id, "speed-4");
309        assert_eq!(clip.override_history_clip_id, "ovh-5");
310        assert_eq!(clip.override_future_clip_id, "ovf-6");
311
312        assert_eq!(
313            clip.history,
314            vec![
315                HistoryEntry {
316                    id: "0a3c311a-hist".to_owned(),
317                    infill: false,
318                    continue_at: Some(115.35),
319                    ..Default::default()
320                },
321                HistoryEntry {
322                    id: "infill-hist".to_owned(),
323                    infill: true,
324                    infill_start_s: Some(12.0),
325                    infill_end_s: Some(28.5),
326                    infill_lyrics: "new words here".to_owned(),
327                    ..Default::default()
328                },
329            ]
330        );
331
332        assert_eq!(
333            clip.concat_history,
334            vec![
335                HistoryEntry {
336                    id: "122d0d15-base".to_owned(),
337                    continue_at: Some(131.5),
338                    ..Default::default()
339                },
340                HistoryEntry {
341                    id: "cf7cb30f-part".to_owned(),
342                    ..Default::default()
343                },
344            ]
345        );
346    }
347
348    #[test]
349    fn bare_string_history_element_parses_to_id_only_entry() {
350        let raw = serde_json::json!({
351            "id": "self",
352            "metadata": {"history": ["m_bare-id-verbatim"]}
353        });
354
355        let clip = Clip::from_json(&raw);
356
357        assert_eq!(
358            clip.history,
359            vec![HistoryEntry {
360                id: "m_bare-id-verbatim".to_owned(),
361                ..Default::default()
362            }]
363        );
364    }
365
366    #[test]
367    fn play_count_parses_top_level_and_defaults_to_zero() {
368        let with_count = serde_json::json!({"id": "x", "play_count": 4242});
369        assert_eq!(Clip::from_json(&with_count).play_count, 4242);
370        // Absent or non-integer play_count falls back to zero.
371        assert_eq!(
372            Clip::from_json(&serde_json::json!({"id": "x"})).play_count,
373            0
374        );
375        assert_eq!(
376            Clip::from_json(&serde_json::json!({"id": "x", "play_count": null})).play_count,
377            0
378        );
379    }
380
381    #[test]
382    fn has_stem_parses_from_metadata_and_defaults_to_false() {
383        // Present and true in metadata.
384        let with_stem = serde_json::json!({"id": "x", "metadata": {"has_stem": true}});
385        assert!(Clip::from_json(&with_stem).has_stem);
386        // Absent, null, or non-bool metadata.has_stem defaults to false, so a
387        // clip is never mistaken for a stem source without an explicit true.
388        assert!(!Clip::from_json(&serde_json::json!({"id": "x"})).has_stem);
389        assert!(
390            !Clip::from_json(&serde_json::json!({"id": "x", "metadata": {"has_stem": null}}))
391                .has_stem
392        );
393        assert!(
394            !Clip::from_json(&serde_json::json!({"id": "x", "metadata": {"has_stem": false}}))
395                .has_stem
396        );
397    }
398
399    #[test]
400    fn absent_or_null_lineage_metadata_defaults_to_empty() {
401        let raw = serde_json::json!({
402            "id": "self",
403            "metadata": {
404                "cover_clip_id": null,
405                "is_remix": null,
406                "history": null
407            }
408        });
409
410        let clip = Clip::from_json(&raw);
411
412        assert_eq!(clip.task, "");
413        assert!(!clip.is_remix);
414        assert!(!clip.is_trashed);
415        assert_eq!(clip.cover_clip_id, "");
416        assert_eq!(clip.upsample_clip_id, "");
417        assert_eq!(clip.remaster_clip_id, "");
418        assert_eq!(clip.speed_clip_id, "");
419        assert_eq!(clip.override_history_clip_id, "");
420        assert_eq!(clip.override_future_clip_id, "");
421        assert!(clip.history.is_empty());
422        assert!(clip.concat_history.is_empty());
423    }
424}