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        self.cover_candidates().into_iter().next()
161    }
162}
163
164/// Read a string field, defaulting to empty when missing or not a string.
165fn string(value: &Value, key: &str) -> String {
166    value
167        .get(key)
168        .and_then(Value::as_str)
169        .unwrap_or("")
170        .to_string()
171}
172
173/// Read a bool field, defaulting to `false` when missing or not a bool.
174fn bool_field(value: &Value, key: &str) -> bool {
175    value.get(key).and_then(Value::as_bool).unwrap_or(false)
176}
177
178/// Read a CDN URL field, rewriting the unreliable `cdn2` host to `cdn1`.
179fn cdn(value: &Value, key: &str) -> String {
180    string(value, key).replace("cdn2.suno.ai", "cdn1.suno.ai")
181}
182
183/// Read `value[key]` as an array of history records into [`HistoryEntry`]s.
184///
185/// Each element is mapped verbatim: a bare JSON string becomes an entry with
186/// only its `id` set, while an object supplies `id`, `infill`, `continue_at`,
187/// `infill_start_s`, `infill_end_s`, and `infill_lyrics`. Anything else (a
188/// missing key, a non-array, or an unexpected element type) yields an empty
189/// `Vec` or a defaulted entry, so parsing never fails.
190fn history_entries(value: &Value, key: &str) -> Vec<HistoryEntry> {
191    let Some(Value::Array(items)) = value.get(key) else {
192        return Vec::new();
193    };
194    items
195        .iter()
196        .map(|item| match item {
197            Value::String(id) => HistoryEntry {
198                id: id.clone(),
199                ..HistoryEntry::default()
200            },
201            _ => HistoryEntry {
202                id: string(item, "id"),
203                infill: bool_field(item, "infill"),
204                continue_at: item.get("continue_at").and_then(Value::as_f64),
205                infill_start_s: item.get("infill_start_s").and_then(Value::as_f64),
206                infill_end_s: item.get("infill_end_s").and_then(Value::as_f64),
207                infill_lyrics: string(item, "infill_lyrics"),
208            },
209        })
210        .collect()
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    fn art_clip(image_large: &str, image: &str, video_cover: &str) -> Clip {
218        Clip {
219            image_large_url: image_large.to_owned(),
220            image_url: image.to_owned(),
221            video_cover_url: video_cover.to_owned(),
222            ..Default::default()
223        }
224    }
225
226    #[test]
227    fn mp3_url_uses_audio_url_or_synthesises_the_cdn_url() {
228        let mut clip = Clip {
229            id: "z".to_owned(),
230            audio_url: "https://x/real.mp3".to_owned(),
231            ..Default::default()
232        };
233        assert_eq!(clip.mp3_url(), "https://x/real.mp3");
234        clip.audio_url = String::new();
235        assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");
236    }
237
238    #[test]
239    fn cover_candidates_are_ordered_and_filtered() {
240        let clip = art_clip("L", "", "V");
241        assert_eq!(clip.cover_candidates(), vec!["L", "V"]);
242    }
243
244    #[test]
245    fn selected_image_url_prefers_large_then_image_then_video() {
246        assert_eq!(art_clip("L", "I", "V").selected_image_url(), Some("L"));
247        assert_eq!(art_clip("", "I", "V").selected_image_url(), Some("I"));
248        assert_eq!(art_clip("", "", "V").selected_image_url(), Some("V"));
249        assert_eq!(art_clip("", "", "").selected_image_url(), None);
250    }
251
252    #[test]
253    fn from_json_parses_all_lineage_metadata_fields() {
254        let raw = serde_json::json!({
255            "id": "self",
256            "title": "Lineage",
257            "is_trashed": true,
258            "metadata": {
259                "task": "extend",
260                "is_remix": true,
261                "cover_clip_id": "cover-1",
262                "upsample_clip_id": "upsample-2",
263                "remaster_clip_id": "remaster-3",
264                "speed_clip_id": "speed-4",
265                "override_history_clip_id": "ovh-5",
266                "override_future_clip_id": "ovf-6",
267                "history": [
268                    {
269                        "infill": false,
270                        "id": "0a3c311a-hist",
271                        "source": "ios",
272                        "type": "gen",
273                        "continue_at": 115.35
274                    },
275                    {
276                        "infill": true,
277                        "id": "infill-hist",
278                        "source": "web",
279                        "type": "gen",
280                        "infill_start_s": 12.0,
281                        "infill_end_s": 28.5,
282                        "infill_lyrics": "new words here"
283                    }
284                ],
285                "concat_history": [
286                    {"infill": false, "id": "122d0d15-base", "continue_at": 131.5},
287                    {"id": "cf7cb30f-part"}
288                ]
289            }
290        });
291
292        let clip = Clip::from_json(&raw);
293
294        assert_eq!(clip.task, "extend");
295        assert!(clip.is_remix);
296        assert!(clip.is_trashed);
297        assert_eq!(clip.cover_clip_id, "cover-1");
298        assert_eq!(clip.upsample_clip_id, "upsample-2");
299        assert_eq!(clip.remaster_clip_id, "remaster-3");
300        assert_eq!(clip.speed_clip_id, "speed-4");
301        assert_eq!(clip.override_history_clip_id, "ovh-5");
302        assert_eq!(clip.override_future_clip_id, "ovf-6");
303
304        assert_eq!(
305            clip.history,
306            vec![
307                HistoryEntry {
308                    id: "0a3c311a-hist".to_owned(),
309                    infill: false,
310                    continue_at: Some(115.35),
311                    ..Default::default()
312                },
313                HistoryEntry {
314                    id: "infill-hist".to_owned(),
315                    infill: true,
316                    infill_start_s: Some(12.0),
317                    infill_end_s: Some(28.5),
318                    infill_lyrics: "new words here".to_owned(),
319                    ..Default::default()
320                },
321            ]
322        );
323
324        assert_eq!(
325            clip.concat_history,
326            vec![
327                HistoryEntry {
328                    id: "122d0d15-base".to_owned(),
329                    continue_at: Some(131.5),
330                    ..Default::default()
331                },
332                HistoryEntry {
333                    id: "cf7cb30f-part".to_owned(),
334                    ..Default::default()
335                },
336            ]
337        );
338    }
339
340    #[test]
341    fn bare_string_history_element_parses_to_id_only_entry() {
342        let raw = serde_json::json!({
343            "id": "self",
344            "metadata": {"history": ["m_bare-id-verbatim"]}
345        });
346
347        let clip = Clip::from_json(&raw);
348
349        assert_eq!(
350            clip.history,
351            vec![HistoryEntry {
352                id: "m_bare-id-verbatim".to_owned(),
353                ..Default::default()
354            }]
355        );
356    }
357
358    #[test]
359    fn play_count_parses_top_level_and_defaults_to_zero() {
360        let with_count = serde_json::json!({"id": "x", "play_count": 4242});
361        assert_eq!(Clip::from_json(&with_count).play_count, 4242);
362        // Absent or non-integer play_count falls back to zero.
363        assert_eq!(
364            Clip::from_json(&serde_json::json!({"id": "x"})).play_count,
365            0
366        );
367        assert_eq!(
368            Clip::from_json(&serde_json::json!({"id": "x", "play_count": null})).play_count,
369            0
370        );
371    }
372
373    #[test]
374    fn has_stem_parses_from_metadata_and_defaults_to_false() {
375        // Present and true in metadata.
376        let with_stem = serde_json::json!({"id": "x", "metadata": {"has_stem": true}});
377        assert!(Clip::from_json(&with_stem).has_stem);
378        // Absent, null, or non-bool metadata.has_stem defaults to false, so a
379        // clip is never mistaken for a stem source without an explicit true.
380        assert!(!Clip::from_json(&serde_json::json!({"id": "x"})).has_stem);
381        assert!(
382            !Clip::from_json(&serde_json::json!({"id": "x", "metadata": {"has_stem": null}}))
383                .has_stem
384        );
385        assert!(
386            !Clip::from_json(&serde_json::json!({"id": "x", "metadata": {"has_stem": false}}))
387                .has_stem
388        );
389    }
390
391    #[test]
392    fn absent_or_null_lineage_metadata_defaults_to_empty() {
393        let raw = serde_json::json!({
394            "id": "self",
395            "metadata": {
396                "cover_clip_id": null,
397                "is_remix": null,
398                "history": null
399            }
400        });
401
402        let clip = Clip::from_json(&raw);
403
404        assert_eq!(clip.task, "");
405        assert!(!clip.is_remix);
406        assert!(!clip.is_trashed);
407        assert_eq!(clip.cover_clip_id, "");
408        assert_eq!(clip.upsample_clip_id, "");
409        assert_eq!(clip.remaster_clip_id, "");
410        assert_eq!(clip.speed_clip_id, "");
411        assert_eq!(clip.override_history_clip_id, "");
412        assert_eq!(clip.override_future_clip_id, "");
413        assert!(clip.history.is_empty());
414        assert!(clip.concat_history.is_empty());
415    }
416}