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: bool_field(raw, "is_liked"),
105            is_trashed: bool_field(raw, "is_trashed"),
106            has_vocal: bool_field(&metadata, "has_vocal"),
107            clip_type: string(&metadata, "type"),
108            prompt: string(&metadata, "prompt"),
109            gpt_description_prompt: string(&metadata, "gpt_description_prompt"),
110            lyrics: string(raw, "lyrics"),
111            model_name: string(raw, "model_name"),
112            major_model_version: string(raw, "major_model_version"),
113            album_title: string(raw, "album_title"),
114            root_ancestor_id: string(raw, "root_ancestor_id"),
115            lineage_status: string(raw, "lineage_status"),
116            edited_clip_id: string(&metadata, "edited_clip_id"),
117            task: string(&metadata, "task"),
118            is_remix: bool_field(&metadata, "is_remix"),
119            cover_clip_id: string(&metadata, "cover_clip_id"),
120            upsample_clip_id: string(&metadata, "upsample_clip_id"),
121            remaster_clip_id: string(&metadata, "remaster_clip_id"),
122            speed_clip_id: string(&metadata, "speed_clip_id"),
123            override_history_clip_id: string(&metadata, "override_history_clip_id"),
124            override_future_clip_id: string(&metadata, "override_future_clip_id"),
125            history: history_entries(&metadata, "history"),
126            concat_history: history_entries(&metadata, "concat_history"),
127        }
128    }
129
130    /// The MP3 source URL: the clip's `audio_url`, or the deterministic CDN URL
131    /// when it is empty.
132    pub fn mp3_url(&self) -> String {
133        if self.audio_url.is_empty() {
134            format!("{CDN_BASE_URL}/{}.mp3", self.id)
135        } else {
136            self.audio_url.clone()
137        }
138    }
139
140    /// Cover-art URLs in preference order (large image, image, video cover),
141    /// dropping any that are empty.
142    pub fn cover_candidates(&self) -> Vec<&str> {
143        [
144            self.image_large_url.as_str(),
145            self.image_url.as_str(),
146            self.video_cover_url.as_str(),
147        ]
148        .into_iter()
149        .filter(|url| !url.is_empty())
150        .collect()
151    }
152
153    /// The preferred cover-art URL, or `None` when the clip carries no art.
154    pub fn selected_image_url(&self) -> Option<&str> {
155        self.cover_candidates().into_iter().next()
156    }
157}
158
159/// Read a string field, defaulting to empty when missing or not a string.
160fn string(value: &Value, key: &str) -> String {
161    value
162        .get(key)
163        .and_then(Value::as_str)
164        .unwrap_or("")
165        .to_string()
166}
167
168/// Read a bool field, defaulting to `false` when missing or not a bool.
169fn bool_field(value: &Value, key: &str) -> bool {
170    value.get(key).and_then(Value::as_bool).unwrap_or(false)
171}
172
173/// Read a CDN URL field, rewriting the unreliable `cdn2` host to `cdn1`.
174fn cdn(value: &Value, key: &str) -> String {
175    string(value, key).replace("cdn2.suno.ai", "cdn1.suno.ai")
176}
177
178/// Read `value[key]` as an array of history records into [`HistoryEntry`]s.
179///
180/// Each element is mapped verbatim: a bare JSON string becomes an entry with
181/// only its `id` set, while an object supplies `id`, `infill`, `continue_at`,
182/// `infill_start_s`, `infill_end_s`, and `infill_lyrics`. Anything else (a
183/// missing key, a non-array, or an unexpected element type) yields an empty
184/// `Vec` or a defaulted entry, so parsing never fails.
185fn history_entries(value: &Value, key: &str) -> Vec<HistoryEntry> {
186    let Some(Value::Array(items)) = value.get(key) else {
187        return Vec::new();
188    };
189    items
190        .iter()
191        .map(|item| match item {
192            Value::String(id) => HistoryEntry {
193                id: id.clone(),
194                ..HistoryEntry::default()
195            },
196            _ => HistoryEntry {
197                id: string(item, "id"),
198                infill: bool_field(item, "infill"),
199                continue_at: item.get("continue_at").and_then(Value::as_f64),
200                infill_start_s: item.get("infill_start_s").and_then(Value::as_f64),
201                infill_end_s: item.get("infill_end_s").and_then(Value::as_f64),
202                infill_lyrics: string(item, "infill_lyrics"),
203            },
204        })
205        .collect()
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn art_clip(image_large: &str, image: &str, video_cover: &str) -> Clip {
213        Clip {
214            image_large_url: image_large.to_owned(),
215            image_url: image.to_owned(),
216            video_cover_url: video_cover.to_owned(),
217            ..Default::default()
218        }
219    }
220
221    #[test]
222    fn mp3_url_uses_audio_url_or_synthesises_the_cdn_url() {
223        let mut clip = Clip {
224            id: "z".to_owned(),
225            audio_url: "https://x/real.mp3".to_owned(),
226            ..Default::default()
227        };
228        assert_eq!(clip.mp3_url(), "https://x/real.mp3");
229        clip.audio_url = String::new();
230        assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");
231    }
232
233    #[test]
234    fn cover_candidates_are_ordered_and_filtered() {
235        let clip = art_clip("L", "", "V");
236        assert_eq!(clip.cover_candidates(), vec!["L", "V"]);
237    }
238
239    #[test]
240    fn selected_image_url_prefers_large_then_image_then_video() {
241        assert_eq!(art_clip("L", "I", "V").selected_image_url(), Some("L"));
242        assert_eq!(art_clip("", "I", "V").selected_image_url(), Some("I"));
243        assert_eq!(art_clip("", "", "V").selected_image_url(), Some("V"));
244        assert_eq!(art_clip("", "", "").selected_image_url(), None);
245    }
246
247    #[test]
248    fn from_json_parses_all_lineage_metadata_fields() {
249        let raw = serde_json::json!({
250            "id": "self",
251            "title": "Lineage",
252            "is_trashed": true,
253            "metadata": {
254                "task": "extend",
255                "is_remix": true,
256                "cover_clip_id": "cover-1",
257                "upsample_clip_id": "upsample-2",
258                "remaster_clip_id": "remaster-3",
259                "speed_clip_id": "speed-4",
260                "override_history_clip_id": "ovh-5",
261                "override_future_clip_id": "ovf-6",
262                "history": [
263                    {
264                        "infill": false,
265                        "id": "0a3c311a-hist",
266                        "source": "ios",
267                        "type": "gen",
268                        "continue_at": 115.35
269                    },
270                    {
271                        "infill": true,
272                        "id": "infill-hist",
273                        "source": "web",
274                        "type": "gen",
275                        "infill_start_s": 12.0,
276                        "infill_end_s": 28.5,
277                        "infill_lyrics": "new words here"
278                    }
279                ],
280                "concat_history": [
281                    {"infill": false, "id": "122d0d15-base", "continue_at": 131.5},
282                    {"id": "cf7cb30f-part"}
283                ]
284            }
285        });
286
287        let clip = Clip::from_json(&raw);
288
289        assert_eq!(clip.task, "extend");
290        assert!(clip.is_remix);
291        assert!(clip.is_trashed);
292        assert_eq!(clip.cover_clip_id, "cover-1");
293        assert_eq!(clip.upsample_clip_id, "upsample-2");
294        assert_eq!(clip.remaster_clip_id, "remaster-3");
295        assert_eq!(clip.speed_clip_id, "speed-4");
296        assert_eq!(clip.override_history_clip_id, "ovh-5");
297        assert_eq!(clip.override_future_clip_id, "ovf-6");
298
299        assert_eq!(
300            clip.history,
301            vec![
302                HistoryEntry {
303                    id: "0a3c311a-hist".to_owned(),
304                    infill: false,
305                    continue_at: Some(115.35),
306                    ..Default::default()
307                },
308                HistoryEntry {
309                    id: "infill-hist".to_owned(),
310                    infill: true,
311                    infill_start_s: Some(12.0),
312                    infill_end_s: Some(28.5),
313                    infill_lyrics: "new words here".to_owned(),
314                    ..Default::default()
315                },
316            ]
317        );
318
319        assert_eq!(
320            clip.concat_history,
321            vec![
322                HistoryEntry {
323                    id: "122d0d15-base".to_owned(),
324                    continue_at: Some(131.5),
325                    ..Default::default()
326                },
327                HistoryEntry {
328                    id: "cf7cb30f-part".to_owned(),
329                    ..Default::default()
330                },
331            ]
332        );
333    }
334
335    #[test]
336    fn bare_string_history_element_parses_to_id_only_entry() {
337        let raw = serde_json::json!({
338            "id": "self",
339            "metadata": {"history": ["m_bare-id-verbatim"]}
340        });
341
342        let clip = Clip::from_json(&raw);
343
344        assert_eq!(
345            clip.history,
346            vec![HistoryEntry {
347                id: "m_bare-id-verbatim".to_owned(),
348                ..Default::default()
349            }]
350        );
351    }
352
353    #[test]
354    fn play_count_parses_top_level_and_defaults_to_zero() {
355        let with_count = serde_json::json!({"id": "x", "play_count": 4242});
356        assert_eq!(Clip::from_json(&with_count).play_count, 4242);
357        // Absent or non-integer play_count falls back to zero.
358        assert_eq!(
359            Clip::from_json(&serde_json::json!({"id": "x"})).play_count,
360            0
361        );
362        assert_eq!(
363            Clip::from_json(&serde_json::json!({"id": "x", "play_count": null})).play_count,
364            0
365        );
366    }
367
368    #[test]
369    fn absent_or_null_lineage_metadata_defaults_to_empty() {
370        let raw = serde_json::json!({
371            "id": "self",
372            "metadata": {
373                "cover_clip_id": null,
374                "is_remix": null,
375                "history": null
376            }
377        });
378
379        let clip = Clip::from_json(&raw);
380
381        assert_eq!(clip.task, "");
382        assert!(!clip.is_remix);
383        assert!(!clip.is_trashed);
384        assert_eq!(clip.cover_clip_id, "");
385        assert_eq!(clip.upsample_clip_id, "");
386        assert_eq!(clip.remaster_clip_id, "");
387        assert_eq!(clip.speed_clip_id, "");
388        assert_eq!(clip.override_history_clip_id, "");
389        assert_eq!(clip.override_future_clip_id, "");
390        assert!(clip.history.is_empty());
391        assert!(clip.concat_history.is_empty());
392    }
393}