1use serde_json::Value;
5
6use crate::model::{Clip, ClipRoot, HistoryEntry, MediaUrl, cdn_audio_url};
7
8pub(crate) fn parse_clip(body: &[u8]) -> Option<Clip> {
11 let data: Value = serde_json::from_slice(body).ok()?;
12 let raw = unwrap_clip(&data);
13 let has_id = raw
14 .get("id")
15 .and_then(Value::as_str)
16 .is_some_and(|id| !id.is_empty());
17 has_id.then(|| Clip::from_json(raw))
18}
19
20pub(crate) fn parse_songs_batch(body: &[u8]) -> Option<Vec<Clip>> {
25 let data: Value = serde_json::from_slice(body).ok()?;
26 let clips = data.get("clips")?.as_array()?;
27 Some(
28 clips
29 .iter()
30 .map(Clip::from_json)
31 .filter(|clip| !clip.id.is_empty())
32 .collect(),
33 )
34}
35
36pub(super) fn unwrap_clip(value: &Value) -> &Value {
39 value
40 .get("clip")
41 .filter(|clip| clip.is_object())
42 .unwrap_or(value)
43}
44
45impl Clip {
46 pub fn from_json(raw: &Value) -> Clip {
52 let metadata = raw.get("metadata").cloned().unwrap_or(Value::Null);
53 let id = string(raw, "id");
54
55 let audio_url = cdn_audio_url(&string(raw, "audio_url"), &id);
56
57 let title = match raw.get("title") {
58 Some(Value::String(title)) => title.clone(),
59 _ => "Untitled".to_string(),
60 };
61
62 Clip {
63 id,
64 title,
65 audio_url,
66 media_urls: parse_media_urls(raw),
67 image_url: cdn(raw, "image_url"),
68 image_large_url: cdn(raw, "image_large_url"),
69 video_url: cdn(raw, "video_url"),
70 video_cover_url: cdn(raw, "video_cover_url"),
71 tags: string(&metadata, "tags"),
72 duration: metadata
73 .get("duration")
74 .and_then(Value::as_f64)
75 .unwrap_or(0.0),
76 play_count: raw.get("play_count").and_then(Value::as_u64).unwrap_or(0),
77 status: raw
78 .get("status")
79 .and_then(Value::as_str)
80 .unwrap_or("unknown")
81 .to_string(),
82 created_at: string(raw, "created_at"),
83 display_name: string_or(raw, "display_name", "user_display_name"),
84 handle: string_or(raw, "handle", "user_handle"),
85 user_id: string(raw, "user_id"),
86 batch_index: raw.get("batch_index").and_then(Value::as_i64),
87 avatar_image_url: string_or(raw, "avatar_image_url", "user_avatar_image_url"),
88 is_liked: bool_field(raw, "is_liked"),
89 is_trashed: bool_field(raw, "is_trashed"),
90 has_vocal: bool_field(&metadata, "has_vocal"),
91 has_stem: bool_field(&metadata, "has_stem"),
92 stem_from_id: string(&metadata, "stem_from_id"),
93 stem_task: string(&metadata, "stem_task"),
94 stem_type_id: int_tolerant(&metadata, "stem_type_id"),
95 stem_type_group_name: string(&metadata, "stem_type_group_name"),
96 clip_type: string(&metadata, "type"),
97 prompt: string(&metadata, "prompt"),
98 gpt_description_prompt: string(&metadata, "gpt_description_prompt"),
99 lyrics: string(raw, "lyrics"),
100 model_name: string(raw, "model_name"),
101 major_model_version: string(raw, "major_model_version"),
102 edited_clip_id: string(&metadata, "edited_clip_id"),
103 task: string(&metadata, "task"),
104 is_remix: bool_field(&metadata, "is_remix"),
105 cover_clip_id: string(&metadata, "cover_clip_id"),
106 upsample_clip_id: string(&metadata, "upsample_clip_id"),
107 remaster_clip_id: string(&metadata, "remaster_clip_id"),
108 speed_clip_id: string(&metadata, "speed_clip_id"),
109 override_history_clip_id: string(&metadata, "override_history_clip_id"),
110 override_future_clip_id: string(&metadata, "override_future_clip_id"),
111 history: history_entries(&metadata, "history"),
112 concat_history: history_entries(&metadata, "concat_history"),
113 clip_roots: parse_clip_roots(raw),
114 clip_attribution_type: raw
115 .get("clip_roots")
116 .map(|roots| string(roots, "clip_attribution_type"))
117 .unwrap_or_default(),
118 }
119 }
120}
121
122fn string(value: &Value, key: &str) -> String {
124 value
125 .get(key)
126 .and_then(Value::as_str)
127 .unwrap_or("")
128 .to_string()
129}
130
131fn string_or(value: &Value, primary: &str, fallback: &str) -> String {
137 let first = string(value, primary);
138 if first.is_empty() {
139 string(value, fallback)
140 } else {
141 first
142 }
143}
144
145fn bool_field(value: &Value, key: &str) -> bool {
147 value.get(key).and_then(Value::as_bool).unwrap_or(false)
148}
149
150fn int_tolerant(value: &Value, key: &str) -> Option<i64> {
154 let field = value.get(key)?;
155 field.as_i64().or_else(|| {
156 field
157 .as_f64()
158 .filter(|number| number.fract() == 0.0)
159 .map(|number| number as i64)
160 })
161}
162
163fn cdn(value: &Value, key: &str) -> String {
165 string(value, key).replace("cdn2.suno.ai", "cdn1.suno.ai")
166}
167
168fn parse_clip_roots(raw: &Value) -> Vec<ClipRoot> {
175 let Some(Value::Array(items)) = raw.get("clip_roots").and_then(|roots| roots.get("clips"))
176 else {
177 return Vec::new();
178 };
179 items
180 .iter()
181 .map(|item| ClipRoot {
182 id: string(item, "id"),
183 title: string(item, "title"),
184 image_url: cdn(item, "image_url"),
185 is_public: bool_field(item, "is_public"),
186 display_name: string(item, "user_display_name"),
187 handle: string(item, "user_handle"),
188 avatar_image_url: string(item, "user_avatar_image_url"),
189 })
190 .collect()
191}
192
193fn parse_media_urls(raw: &Value) -> Vec<MediaUrl> {
198 let Some(Value::Array(items)) = raw.get("media_urls") else {
199 return Vec::new();
200 };
201 items
202 .iter()
203 .map(|item| MediaUrl {
204 url: string(item, "url"),
205 content_type: string(item, "content_type"),
206 delivery: string(item, "delivery"),
207 encoding: string(item, "encoding"),
208 })
209 .collect()
210}
211
212fn history_entries(value: &Value, key: &str) -> Vec<HistoryEntry> {
220 let Some(Value::Array(items)) = value.get(key) else {
221 return Vec::new();
222 };
223 items
224 .iter()
225 .map(|item| match item {
226 Value::String(id) => HistoryEntry {
227 id: id.clone(),
228 ..HistoryEntry::default()
229 },
230 _ => HistoryEntry {
231 id: string(item, "id"),
232 infill: bool_field(item, "infill"),
233 continue_at: item.get("continue_at").and_then(Value::as_f64),
234 infill_start_s: item.get("infill_start_s").and_then(Value::as_f64),
235 infill_end_s: item.get("infill_end_s").and_then(Value::as_f64),
236 infill_lyrics: string(item, "infill_lyrics"),
237 },
238 })
239 .collect()
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn audiopipe_url_is_rewritten_to_cdn() {
248 let raw =
249 serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
250 assert_eq!(
251 Clip::from_json(&raw).audio_url,
252 "https://cdn1.suno.ai/x.mp3"
253 );
254 }
255
256 #[test]
257 fn parse_clip_accepts_bare_and_wrapped_shapes() {
258 let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
259 assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
260
261 let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
262 assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
263
264 let missing = serde_json::json!({"detail": "not found"}).to_string();
265 assert!(parse_clip(missing.as_bytes()).is_none());
266 }
267
268 #[test]
269 fn from_json_reads_media_urls_user_id_and_batch_index() {
270 let raw = serde_json::json!({
271 "id": "clip-1",
272 "user_id": "owner-9",
273 "batch_index": 23,
274 "media_urls": [
275 {
276 "url": "https://media/clip-1.m4a",
277 "content_type": "m4a-opus",
278 "delivery": "progressive",
279 "encoding": "1.0.0"
280 },
281 {
282 "url": "https://cdn1.suno.ai/clip-1.mp3",
283 "content_type": "mp3",
284 "delivery": "progressive"
285 }
286 ]
287 });
288
289 let clip = Clip::from_json(&raw);
290
291 assert_eq!(clip.user_id, "owner-9");
292 assert_eq!(clip.batch_index, Some(23));
293 assert_eq!(clip.media_urls.len(), 2);
294 assert_eq!(clip.media_urls[0].content_type, "m4a-opus");
295 assert_eq!(clip.media_urls[0].encoding, "1.0.0");
296 assert_eq!(clip.media_urls[1].content_type, "mp3");
298 assert_eq!(clip.media_urls[1].encoding, "");
299 assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/clip-1.mp3");
300 }
301
302 #[test]
303 fn from_json_defaults_media_urls_user_id_and_batch_index_when_absent() {
304 let clip = Clip::from_json(&serde_json::json!({"id": "clip-1"}));
305 assert!(clip.media_urls.is_empty());
306 assert_eq!(clip.user_id, "");
307 assert_eq!(clip.batch_index, None);
308 let odd = Clip::from_json(&serde_json::json!({"id": "x", "media_urls": "nope"}));
310 assert!(odd.media_urls.is_empty());
311 }
312
313 #[test]
314 fn from_json_parses_nested_clip_roots_and_owner_identity() {
315 let raw = serde_json::json!({
319 "id": "00000000-0000-4000-8000-000000000017",
320 "title": "Track 1",
321 "user_id": "00000000-0000-4000-8000-000000000019",
322 "display_name": "Example Artist 4",
323 "handle": "example-artist-1",
324 "avatar_image_url": "https://cdn1.suno.ai/avatar.jpg",
325 "batch_index": 1,
326 "clip_roots": {
327 "clips": [
328 {
329 "id": "00000000-0000-4000-8000-000000000020",
330 "title": "Track 2",
331 "image_url": "https://cdn2.suno.ai/image_00000000-0000-4000-8000-000000000020.jpeg",
332 "is_public": false,
333 "user_display_name": "Example Artist 4",
334 "user_handle": "example-artist-1",
335 "user_avatar_image_url": "https://cdn1.suno.ai/avatar.jpg"
336 }
337 ],
338 "clip_attribution_type": "remix"
339 }
340 });
341 let clip = Clip::from_json(&raw);
342
343 assert_eq!(clip.display_name, "Example Artist 4");
344 assert_eq!(clip.handle, "example-artist-1");
345 assert_eq!(clip.avatar_image_url, "https://cdn1.suno.ai/avatar.jpg");
346 assert_eq!(clip.clip_attribution_type, "remix");
347 assert_eq!(clip.clip_roots.len(), 1);
348 let root = &clip.clip_roots[0];
349 assert_eq!(root.id, "00000000-0000-4000-8000-000000000020");
350 assert_eq!(root.title, "Track 2");
351 assert!(!root.is_public);
352 assert_eq!(root.display_name, "Example Artist 4");
354 assert_eq!(root.handle, "example-artist-1");
355 assert_eq!(root.avatar_image_url, "https://cdn1.suno.ai/avatar.jpg");
356 assert_eq!(
358 root.image_url,
359 "https://cdn1.suno.ai/image_00000000-0000-4000-8000-000000000020.jpeg"
360 );
361 }
362
363 #[test]
364 fn from_json_reads_user_prefixed_identity_on_a_parent_shape() {
365 let raw = serde_json::json!({
367 "id": "00000000-0000-4000-8000-000000000020",
368 "title": "Track 2",
369 "is_public": false,
370 "user_display_name": "Example Artist 4",
371 "user_handle": "example-artist-1",
372 "user_avatar_image_url": "https://cdn1.suno.ai/avatar.jpg"
373 });
374 let clip = Clip::from_json(&raw);
375 assert_eq!(clip.display_name, "Example Artist 4");
376 assert_eq!(clip.handle, "example-artist-1");
377 assert_eq!(clip.avatar_image_url, "https://cdn1.suno.ai/avatar.jpg");
378 }
379
380 #[test]
381 fn from_json_prefers_bare_identity_over_user_prefixed() {
382 let raw = serde_json::json!({
384 "id": "x",
385 "display_name": "Bare Name",
386 "user_display_name": "Prefixed Name",
387 "handle": "bare-handle",
388 "user_handle": "prefixed-handle"
389 });
390 let clip = Clip::from_json(&raw);
391 assert_eq!(clip.display_name, "Bare Name");
392 assert_eq!(clip.handle, "bare-handle");
393 }
394
395 #[test]
396 fn from_json_defaults_clip_roots_when_absent_or_malformed() {
397 let none = Clip::from_json(&serde_json::json!({"id": "x"}));
399 assert!(none.clip_roots.is_empty());
400 assert_eq!(none.clip_attribution_type, "");
401
402 let no_clips = Clip::from_json(&serde_json::json!({
404 "id": "x",
405 "clip_roots": {"clip_attribution_type": "remix"}
406 }));
407 assert!(no_clips.clip_roots.is_empty());
408 assert_eq!(no_clips.clip_attribution_type, "remix");
409
410 let odd = Clip::from_json(&serde_json::json!({
411 "id": "x",
412 "clip_roots": {"clips": "nope"}
413 }));
414 assert!(odd.clip_roots.is_empty());
415
416 let array_shape = Clip::from_json(&serde_json::json!({
418 "id": "x",
419 "clip_roots": [{"id": "r"}]
420 }));
421 assert!(array_shape.clip_roots.is_empty());
422 assert_eq!(array_shape.clip_attribution_type, "");
423 }
424
425 #[test]
426 fn from_json_reads_multiple_clip_roots_in_order() {
427 let raw = serde_json::json!({
428 "id": "x",
429 "clip_roots": {
430 "clips": [
431 {"id": "root-a", "title": "A"},
432 {"id": "root-b", "title": "B"}
433 ],
434 "clip_attribution_type": "remix"
435 }
436 });
437 let clip = Clip::from_json(&raw);
438 assert_eq!(clip.clip_roots.len(), 2);
439 assert_eq!(clip.clip_roots[0].id, "root-a");
440 assert_eq!(clip.clip_roots[1].id, "root-b");
441 }
442
443 #[test]
444 fn from_json_parses_all_lineage_metadata_fields() {
445 let raw = serde_json::json!({
446 "id": "self",
447 "title": "Lineage",
448 "is_trashed": true,
449 "metadata": {
450 "task": "extend",
451 "is_remix": true,
452 "cover_clip_id": "cover-1",
453 "upsample_clip_id": "upsample-2",
454 "remaster_clip_id": "remaster-3",
455 "speed_clip_id": "speed-4",
456 "override_history_clip_id": "ovh-5",
457 "override_future_clip_id": "ovf-6",
458 "history": [
459 {
460 "infill": false,
461 "id": "0a3c311a-hist",
462 "source": "ios",
463 "type": "gen",
464 "continue_at": 115.35
465 },
466 {
467 "infill": true,
468 "id": "infill-hist",
469 "source": "web",
470 "type": "gen",
471 "infill_start_s": 12.0,
472 "infill_end_s": 28.5,
473 "infill_lyrics": "new words here"
474 }
475 ],
476 "concat_history": [
477 {"infill": false, "id": "122d0d15-base", "continue_at": 131.5},
478 {"id": "cf7cb30f-part"}
479 ]
480 }
481 });
482
483 let clip = Clip::from_json(&raw);
484
485 assert_eq!(clip.task, "extend");
486 assert!(clip.is_remix);
487 assert!(clip.is_trashed);
488 assert_eq!(clip.cover_clip_id, "cover-1");
489 assert_eq!(clip.upsample_clip_id, "upsample-2");
490 assert_eq!(clip.remaster_clip_id, "remaster-3");
491 assert_eq!(clip.speed_clip_id, "speed-4");
492 assert_eq!(clip.override_history_clip_id, "ovh-5");
493 assert_eq!(clip.override_future_clip_id, "ovf-6");
494
495 assert_eq!(
496 clip.history,
497 vec![
498 HistoryEntry {
499 id: "0a3c311a-hist".to_owned(),
500 infill: false,
501 continue_at: Some(115.35),
502 ..Default::default()
503 },
504 HistoryEntry {
505 id: "infill-hist".to_owned(),
506 infill: true,
507 infill_start_s: Some(12.0),
508 infill_end_s: Some(28.5),
509 infill_lyrics: "new words here".to_owned(),
510 ..Default::default()
511 },
512 ]
513 );
514
515 assert_eq!(
516 clip.concat_history,
517 vec![
518 HistoryEntry {
519 id: "122d0d15-base".to_owned(),
520 continue_at: Some(131.5),
521 ..Default::default()
522 },
523 HistoryEntry {
524 id: "cf7cb30f-part".to_owned(),
525 ..Default::default()
526 },
527 ]
528 );
529 }
530
531 #[test]
532 fn bare_string_history_element_parses_to_id_only_entry() {
533 let raw = serde_json::json!({
534 "id": "self",
535 "metadata": {"history": ["m_bare-id-verbatim"]}
536 });
537
538 let clip = Clip::from_json(&raw);
539
540 assert_eq!(
541 clip.history,
542 vec![HistoryEntry {
543 id: "m_bare-id-verbatim".to_owned(),
544 ..Default::default()
545 }]
546 );
547 }
548
549 #[test]
550 fn play_count_parses_top_level_and_defaults_to_zero() {
551 let with_count = serde_json::json!({"id": "x", "play_count": 4242});
552 assert_eq!(Clip::from_json(&with_count).play_count, 4242);
553 assert_eq!(
555 Clip::from_json(&serde_json::json!({"id": "x"})).play_count,
556 0
557 );
558 assert_eq!(
559 Clip::from_json(&serde_json::json!({"id": "x", "play_count": null})).play_count,
560 0
561 );
562 }
563
564 #[test]
565 fn has_stem_parses_from_metadata_and_defaults_to_false() {
566 let with_stem = serde_json::json!({"id": "x", "metadata": {"has_stem": true}});
568 assert!(Clip::from_json(&with_stem).has_stem);
569 assert!(!Clip::from_json(&serde_json::json!({"id": "x"})).has_stem);
572 assert!(
573 !Clip::from_json(&serde_json::json!({"id": "x", "metadata": {"has_stem": null}}))
574 .has_stem
575 );
576 assert!(
577 !Clip::from_json(&serde_json::json!({"id": "x", "metadata": {"has_stem": false}}))
578 .has_stem
579 );
580 }
581
582 #[test]
583 fn stem_lineage_quartet_parses_from_metadata_float_tolerant() {
584 let raw = serde_json::json!({
587 "id": "stem-child",
588 "metadata": {
589 "has_stem": false,
590 "stem_from_id": "source-074",
591 "stem_task": "twelve",
592 "stem_type_id": 91.0,
593 "stem_type_group_name": "Backing_Vocals"
594 }
595 });
596 let clip = Clip::from_json(&raw);
597 assert_eq!(clip.stem_from_id, "source-074");
598 assert_eq!(clip.stem_task, "twelve");
599 assert_eq!(clip.stem_type_id, Some(91));
600 assert_eq!(clip.stem_type_group_name, "Backing_Vocals");
601 assert!(!clip.has_stem);
604
605 let as_int = serde_json::json!({"id": "x", "metadata": {"stem_type_id": 91}});
607 assert_eq!(Clip::from_json(&as_int).stem_type_id, Some(91));
608
609 let bare = Clip::from_json(&serde_json::json!({"id": "x"}));
613 assert_eq!(bare.stem_type_id, None);
614 assert_eq!(bare.stem_from_id, "");
615 assert_eq!(bare.stem_task, "");
616 assert_eq!(bare.stem_type_group_name, "");
617 for odd in [
618 serde_json::json!({"id": "x", "metadata": {"stem_type_id": null}}),
619 serde_json::json!({"id": "x", "metadata": {"stem_type_id": 91.5}}),
620 serde_json::json!({"id": "x", "metadata": {"stem_type_id": "91"}}),
621 ] {
622 assert_eq!(Clip::from_json(&odd).stem_type_id, None);
623 }
624 }
625
626 #[test]
627 fn absent_or_null_lineage_metadata_defaults_to_empty() {
628 let raw = serde_json::json!({
629 "id": "self",
630 "metadata": {
631 "cover_clip_id": null,
632 "is_remix": null,
633 "history": null
634 }
635 });
636
637 let clip = Clip::from_json(&raw);
638
639 assert_eq!(clip.task, "");
640 assert!(!clip.is_remix);
641 assert!(!clip.is_trashed);
642 assert_eq!(clip.cover_clip_id, "");
643 assert_eq!(clip.upsample_clip_id, "");
644 assert_eq!(clip.remaster_clip_id, "");
645 assert_eq!(clip.speed_clip_id, "");
646 assert_eq!(clip.override_history_clip_id, "");
647 assert_eq!(clip.override_future_clip_id, "");
648 assert!(clip.history.is_empty());
649 assert!(clip.concat_history.is_empty());
650 }
651}