1use serde_json::Value;
4
5use crate::consts::CDN_BASE_URL;
6
7#[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 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#[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 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 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 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 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
172fn 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
181fn bool_field(value: &Value, key: &str) -> bool {
183 value.get(key).and_then(Value::as_bool).unwrap_or(false)
184}
185
186fn cdn(value: &Value, key: &str) -> String {
188 string(value, key).replace("cdn2.suno.ai", "cdn1.suno.ai")
189}
190
191fn 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 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 let with_stem = serde_json::json!({"id": "x", "metadata": {"has_stem": true}});
385 assert!(Clip::from_json(&with_stem).has_stem);
386 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}