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