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 media_urls: Vec<MediaUrl>,
18 pub image_url: String,
19 pub image_large_url: String,
20 pub video_url: String,
21 pub video_cover_url: String,
22 pub tags: String,
23 pub duration: f64,
24 pub play_count: u64,
25 pub status: String,
26 pub created_at: String,
27 pub display_name: String,
28 pub handle: String,
29 pub user_id: String,
33 pub batch_index: Option<i64>,
36 pub avatar_image_url: String,
39 pub is_liked: bool,
40 pub is_trashed: bool,
41 pub has_vocal: bool,
42 pub has_stem: bool,
46 pub stem_from_id: String,
50 pub stem_task: String,
53 pub stem_type_id: Option<i64>,
57 pub stem_type_group_name: String,
61 pub clip_type: String,
62 pub prompt: String,
63 pub gpt_description_prompt: String,
64 pub lyrics: String,
65 pub model_name: String,
66 pub major_model_version: String,
67 pub edited_clip_id: String,
68 pub task: String,
69 pub is_remix: bool,
70 pub cover_clip_id: String,
71 pub upsample_clip_id: String,
72 pub remaster_clip_id: String,
73 pub speed_clip_id: String,
74 pub override_history_clip_id: String,
75 pub override_future_clip_id: String,
76 pub history: Vec<HistoryEntry>,
77 pub concat_history: Vec<HistoryEntry>,
78 pub clip_roots: Vec<ClipRoot>,
83 pub clip_attribution_type: String,
86}
87
88#[derive(Debug, Clone, Default, PartialEq)]
95pub struct ClipRoot {
96 pub id: String,
97 pub title: String,
98 pub image_url: String,
99 pub is_public: bool,
100 pub display_name: String,
101 pub handle: String,
102 pub avatar_image_url: String,
103}
104
105#[derive(Debug, Clone, Default, PartialEq)]
113pub struct MediaUrl {
114 pub url: String,
115 pub content_type: String,
116 pub delivery: String,
117 pub encoding: String,
118}
119
120#[derive(Debug, Clone, Default, PartialEq)]
124pub struct HistoryEntry {
125 pub id: String,
126 pub infill: bool,
127 pub continue_at: Option<f64>,
128 pub infill_start_s: Option<f64>,
129 pub infill_end_s: Option<f64>,
130 pub infill_lyrics: String,
131}
132
133impl Clip {
134 pub fn from_json(raw: &Value) -> Clip {
140 let metadata = raw.get("metadata").cloned().unwrap_or(Value::Null);
141 let id = string(raw, "id");
142
143 let audio_url = cdn_audio_url(&string(raw, "audio_url"), &id);
144
145 let title = match raw.get("title") {
146 Some(Value::String(title)) => title.clone(),
147 _ => "Untitled".to_string(),
148 };
149
150 Clip {
151 id,
152 title,
153 audio_url,
154 media_urls: parse_media_urls(raw),
155 image_url: cdn(raw, "image_url"),
156 image_large_url: cdn(raw, "image_large_url"),
157 video_url: cdn(raw, "video_url"),
158 video_cover_url: cdn(raw, "video_cover_url"),
159 tags: string(&metadata, "tags"),
160 duration: metadata
161 .get("duration")
162 .and_then(Value::as_f64)
163 .unwrap_or(0.0),
164 play_count: raw.get("play_count").and_then(Value::as_u64).unwrap_or(0),
165 status: raw
166 .get("status")
167 .and_then(Value::as_str)
168 .unwrap_or("unknown")
169 .to_string(),
170 created_at: string(raw, "created_at"),
171 display_name: string_or(raw, "display_name", "user_display_name"),
172 handle: string_or(raw, "handle", "user_handle"),
173 user_id: string(raw, "user_id"),
174 batch_index: raw.get("batch_index").and_then(Value::as_i64),
175 avatar_image_url: string_or(raw, "avatar_image_url", "user_avatar_image_url"),
176 is_liked: bool_field(raw, "is_liked"),
177 is_trashed: bool_field(raw, "is_trashed"),
178 has_vocal: bool_field(&metadata, "has_vocal"),
179 has_stem: bool_field(&metadata, "has_stem"),
180 stem_from_id: string(&metadata, "stem_from_id"),
181 stem_task: string(&metadata, "stem_task"),
182 stem_type_id: int_tolerant(&metadata, "stem_type_id"),
183 stem_type_group_name: string(&metadata, "stem_type_group_name"),
184 clip_type: string(&metadata, "type"),
185 prompt: string(&metadata, "prompt"),
186 gpt_description_prompt: string(&metadata, "gpt_description_prompt"),
187 lyrics: string(raw, "lyrics"),
188 model_name: string(raw, "model_name"),
189 major_model_version: string(raw, "major_model_version"),
190 edited_clip_id: string(&metadata, "edited_clip_id"),
191 task: string(&metadata, "task"),
192 is_remix: bool_field(&metadata, "is_remix"),
193 cover_clip_id: string(&metadata, "cover_clip_id"),
194 upsample_clip_id: string(&metadata, "upsample_clip_id"),
195 remaster_clip_id: string(&metadata, "remaster_clip_id"),
196 speed_clip_id: string(&metadata, "speed_clip_id"),
197 override_history_clip_id: string(&metadata, "override_history_clip_id"),
198 override_future_clip_id: string(&metadata, "override_future_clip_id"),
199 history: history_entries(&metadata, "history"),
200 concat_history: history_entries(&metadata, "concat_history"),
201 clip_roots: parse_clip_roots(raw),
202 clip_attribution_type: raw
203 .get("clip_roots")
204 .map(|roots| string(roots, "clip_attribution_type"))
205 .unwrap_or_default(),
206 }
207 }
208
209 pub fn mp3_url(&self) -> String {
217 if let Some(mp3) = self
218 .media_urls
219 .iter()
220 .find(|media| media.content_type == "mp3" && !media.url.is_empty())
221 {
222 return cdn_audio_url(&mp3.url, &self.id);
223 }
224 if self.audio_url.is_empty() {
225 format!("{CDN_BASE_URL}/{}.mp3", self.id)
226 } else {
227 self.audio_url.clone()
228 }
229 }
230
231 pub fn cover_candidates(&self) -> Vec<&str> {
234 [
235 self.image_large_url.as_str(),
236 self.image_url.as_str(),
237 self.video_cover_url.as_str(),
238 ]
239 .into_iter()
240 .filter(|url| !url.is_empty())
241 .collect()
242 }
243
244 pub fn selected_image_url(&self) -> Option<&str> {
246 if !self.image_large_url.is_empty() {
247 Some(self.image_large_url.as_str())
248 } else if !self.image_url.is_empty() {
249 Some(self.image_url.as_str())
250 } else if !self.video_cover_url.is_empty() {
251 Some(self.video_cover_url.as_str())
252 } else {
253 None
254 }
255 }
256}
257
258fn string(value: &Value, key: &str) -> String {
260 value
261 .get(key)
262 .and_then(Value::as_str)
263 .unwrap_or("")
264 .to_string()
265}
266
267fn string_or(value: &Value, primary: &str, fallback: &str) -> String {
273 let first = string(value, primary);
274 if first.is_empty() {
275 string(value, fallback)
276 } else {
277 first
278 }
279}
280
281fn bool_field(value: &Value, key: &str) -> bool {
283 value.get(key).and_then(Value::as_bool).unwrap_or(false)
284}
285
286fn int_tolerant(value: &Value, key: &str) -> Option<i64> {
290 let field = value.get(key)?;
291 field.as_i64().or_else(|| {
292 field
293 .as_f64()
294 .filter(|number| number.fract() == 0.0)
295 .map(|number| number as i64)
296 })
297}
298
299fn cdn(value: &Value, key: &str) -> String {
301 string(value, key).replace("cdn2.suno.ai", "cdn1.suno.ai")
302}
303
304fn cdn_audio_url(url: &str, id: &str) -> String {
310 if url.contains("audiopipe") && !id.is_empty() {
311 format!("{CDN_BASE_URL}/{id}.mp3")
312 } else {
313 url.to_string()
314 }
315}
316
317fn parse_clip_roots(raw: &Value) -> Vec<ClipRoot> {
324 let Some(Value::Array(items)) = raw.get("clip_roots").and_then(|roots| roots.get("clips"))
325 else {
326 return Vec::new();
327 };
328 items
329 .iter()
330 .map(|item| ClipRoot {
331 id: string(item, "id"),
332 title: string(item, "title"),
333 image_url: cdn(item, "image_url"),
334 is_public: bool_field(item, "is_public"),
335 display_name: string(item, "user_display_name"),
336 handle: string(item, "user_handle"),
337 avatar_image_url: string(item, "user_avatar_image_url"),
338 })
339 .collect()
340}
341
342fn parse_media_urls(raw: &Value) -> Vec<MediaUrl> {
347 let Some(Value::Array(items)) = raw.get("media_urls") else {
348 return Vec::new();
349 };
350 items
351 .iter()
352 .map(|item| MediaUrl {
353 url: string(item, "url"),
354 content_type: string(item, "content_type"),
355 delivery: string(item, "delivery"),
356 encoding: string(item, "encoding"),
357 })
358 .collect()
359}
360
361fn history_entries(value: &Value, key: &str) -> Vec<HistoryEntry> {
369 let Some(Value::Array(items)) = value.get(key) else {
370 return Vec::new();
371 };
372 items
373 .iter()
374 .map(|item| match item {
375 Value::String(id) => HistoryEntry {
376 id: id.clone(),
377 ..HistoryEntry::default()
378 },
379 _ => HistoryEntry {
380 id: string(item, "id"),
381 infill: bool_field(item, "infill"),
382 continue_at: item.get("continue_at").and_then(Value::as_f64),
383 infill_start_s: item.get("infill_start_s").and_then(Value::as_f64),
384 infill_end_s: item.get("infill_end_s").and_then(Value::as_f64),
385 infill_lyrics: string(item, "infill_lyrics"),
386 },
387 })
388 .collect()
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 fn art_clip(image_large: &str, image: &str, video_cover: &str) -> Clip {
396 Clip {
397 image_large_url: image_large.to_owned(),
398 image_url: image.to_owned(),
399 video_cover_url: video_cover.to_owned(),
400 ..Default::default()
401 }
402 }
403
404 #[test]
405 fn mp3_url_uses_audio_url_or_synthesises_the_cdn_url() {
406 let mut clip = Clip {
407 id: "z".to_owned(),
408 audio_url: "https://x/real.mp3".to_owned(),
409 ..Default::default()
410 };
411 assert_eq!(clip.mp3_url(), "https://x/real.mp3");
412 clip.audio_url = String::new();
413 assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");
414 }
415
416 #[test]
417 fn mp3_url_prefers_the_media_urls_mp3_then_audio_url_then_synthesis() {
418 let clip = Clip {
420 id: "z".to_owned(),
421 audio_url: "https://x/real.mp3".to_owned(),
422 media_urls: vec![
423 MediaUrl {
424 url: "https://media/z.m4a".to_owned(),
425 content_type: "m4a-opus".to_owned(),
426 delivery: "progressive".to_owned(),
427 encoding: "1.0.0".to_owned(),
428 },
429 MediaUrl {
430 url: "https://cdn1.suno.ai/z.mp3".to_owned(),
431 content_type: "mp3".to_owned(),
432 delivery: "progressive".to_owned(),
433 encoding: String::new(),
434 },
435 ],
436 ..Default::default()
437 };
438 assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");
439
440 let no_media = Clip {
442 id: "z".to_owned(),
443 audio_url: "https://x/real.mp3".to_owned(),
444 ..Default::default()
445 };
446 assert_eq!(no_media.mp3_url(), "https://x/real.mp3");
447
448 let only_m4a = Clip {
450 id: "z".to_owned(),
451 audio_url: String::new(),
452 media_urls: vec![MediaUrl {
453 url: "https://media/z.m4a".to_owned(),
454 content_type: "m4a-opus".to_owned(),
455 ..Default::default()
456 }],
457 ..Default::default()
458 };
459 assert_eq!(only_m4a.mp3_url(), "https://cdn1.suno.ai/z.mp3");
460 }
461
462 #[test]
463 fn mp3_url_rewrites_an_expiring_audiopipe_media_url() {
464 let expiring = Clip {
467 id: "z".to_owned(),
468 media_urls: vec![MediaUrl {
469 url: "https://audiopipe.suno.ai/item?id=z".to_owned(),
470 content_type: "mp3".to_owned(),
471 ..Default::default()
472 }],
473 ..Default::default()
474 };
475 assert_eq!(expiring.mp3_url(), "https://cdn1.suno.ai/z.mp3");
476
477 let permanent = Clip {
479 id: "z".to_owned(),
480 media_urls: vec![MediaUrl {
481 url: "https://cdn1.suno.ai/z.mp3".to_owned(),
482 content_type: "mp3".to_owned(),
483 ..Default::default()
484 }],
485 ..Default::default()
486 };
487 assert_eq!(permanent.mp3_url(), "https://cdn1.suno.ai/z.mp3");
488 }
489
490 #[test]
491 fn from_json_reads_media_urls_user_id_and_batch_index() {
492 let raw = serde_json::json!({
493 "id": "clip-1",
494 "user_id": "owner-9",
495 "batch_index": 23,
496 "media_urls": [
497 {
498 "url": "https://media/clip-1.m4a",
499 "content_type": "m4a-opus",
500 "delivery": "progressive",
501 "encoding": "1.0.0"
502 },
503 {
504 "url": "https://cdn1.suno.ai/clip-1.mp3",
505 "content_type": "mp3",
506 "delivery": "progressive"
507 }
508 ]
509 });
510
511 let clip = Clip::from_json(&raw);
512
513 assert_eq!(clip.user_id, "owner-9");
514 assert_eq!(clip.batch_index, Some(23));
515 assert_eq!(clip.media_urls.len(), 2);
516 assert_eq!(clip.media_urls[0].content_type, "m4a-opus");
517 assert_eq!(clip.media_urls[0].encoding, "1.0.0");
518 assert_eq!(clip.media_urls[1].content_type, "mp3");
520 assert_eq!(clip.media_urls[1].encoding, "");
521 assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/clip-1.mp3");
522 }
523
524 #[test]
525 fn from_json_defaults_media_urls_user_id_and_batch_index_when_absent() {
526 let clip = Clip::from_json(&serde_json::json!({"id": "clip-1"}));
527 assert!(clip.media_urls.is_empty());
528 assert_eq!(clip.user_id, "");
529 assert_eq!(clip.batch_index, None);
530 let odd = Clip::from_json(&serde_json::json!({"id": "x", "media_urls": "nope"}));
532 assert!(odd.media_urls.is_empty());
533 }
534
535 #[test]
536 fn from_json_parses_nested_clip_roots_and_owner_identity() {
537 let raw = serde_json::json!({
541 "id": "00000000-0000-4000-8000-000000000017",
542 "title": "Track 1",
543 "user_id": "00000000-0000-4000-8000-000000000019",
544 "display_name": "Example Artist 4",
545 "handle": "example-artist-1",
546 "avatar_image_url": "https://cdn1.suno.ai/avatar.jpg",
547 "batch_index": 1,
548 "clip_roots": {
549 "clips": [
550 {
551 "id": "00000000-0000-4000-8000-000000000020",
552 "title": "Track 2",
553 "image_url": "https://cdn2.suno.ai/image_00000000-0000-4000-8000-000000000020.jpeg",
554 "is_public": false,
555 "user_display_name": "Example Artist 4",
556 "user_handle": "example-artist-1",
557 "user_avatar_image_url": "https://cdn1.suno.ai/avatar.jpg"
558 }
559 ],
560 "clip_attribution_type": "remix"
561 }
562 });
563 let clip = Clip::from_json(&raw);
564
565 assert_eq!(clip.display_name, "Example Artist 4");
566 assert_eq!(clip.handle, "example-artist-1");
567 assert_eq!(clip.avatar_image_url, "https://cdn1.suno.ai/avatar.jpg");
568 assert_eq!(clip.clip_attribution_type, "remix");
569 assert_eq!(clip.clip_roots.len(), 1);
570 let root = &clip.clip_roots[0];
571 assert_eq!(root.id, "00000000-0000-4000-8000-000000000020");
572 assert_eq!(root.title, "Track 2");
573 assert!(!root.is_public);
574 assert_eq!(root.display_name, "Example Artist 4");
576 assert_eq!(root.handle, "example-artist-1");
577 assert_eq!(root.avatar_image_url, "https://cdn1.suno.ai/avatar.jpg");
578 assert_eq!(
580 root.image_url,
581 "https://cdn1.suno.ai/image_00000000-0000-4000-8000-000000000020.jpeg"
582 );
583 }
584
585 #[test]
586 fn from_json_reads_user_prefixed_identity_on_a_parent_shape() {
587 let raw = serde_json::json!({
589 "id": "00000000-0000-4000-8000-000000000020",
590 "title": "Track 2",
591 "is_public": false,
592 "user_display_name": "Example Artist 4",
593 "user_handle": "example-artist-1",
594 "user_avatar_image_url": "https://cdn1.suno.ai/avatar.jpg"
595 });
596 let clip = Clip::from_json(&raw);
597 assert_eq!(clip.display_name, "Example Artist 4");
598 assert_eq!(clip.handle, "example-artist-1");
599 assert_eq!(clip.avatar_image_url, "https://cdn1.suno.ai/avatar.jpg");
600 }
601
602 #[test]
603 fn from_json_prefers_bare_identity_over_user_prefixed() {
604 let raw = serde_json::json!({
606 "id": "x",
607 "display_name": "Bare Name",
608 "user_display_name": "Prefixed Name",
609 "handle": "bare-handle",
610 "user_handle": "prefixed-handle"
611 });
612 let clip = Clip::from_json(&raw);
613 assert_eq!(clip.display_name, "Bare Name");
614 assert_eq!(clip.handle, "bare-handle");
615 }
616
617 #[test]
618 fn from_json_defaults_clip_roots_when_absent_or_malformed() {
619 let none = Clip::from_json(&serde_json::json!({"id": "x"}));
621 assert!(none.clip_roots.is_empty());
622 assert_eq!(none.clip_attribution_type, "");
623
624 let no_clips = Clip::from_json(&serde_json::json!({
626 "id": "x",
627 "clip_roots": {"clip_attribution_type": "remix"}
628 }));
629 assert!(no_clips.clip_roots.is_empty());
630 assert_eq!(no_clips.clip_attribution_type, "remix");
631
632 let odd = Clip::from_json(&serde_json::json!({
633 "id": "x",
634 "clip_roots": {"clips": "nope"}
635 }));
636 assert!(odd.clip_roots.is_empty());
637
638 let array_shape = Clip::from_json(&serde_json::json!({
640 "id": "x",
641 "clip_roots": [{"id": "r"}]
642 }));
643 assert!(array_shape.clip_roots.is_empty());
644 assert_eq!(array_shape.clip_attribution_type, "");
645 }
646
647 #[test]
648 fn from_json_reads_multiple_clip_roots_in_order() {
649 let raw = serde_json::json!({
650 "id": "x",
651 "clip_roots": {
652 "clips": [
653 {"id": "root-a", "title": "A"},
654 {"id": "root-b", "title": "B"}
655 ],
656 "clip_attribution_type": "remix"
657 }
658 });
659 let clip = Clip::from_json(&raw);
660 assert_eq!(clip.clip_roots.len(), 2);
661 assert_eq!(clip.clip_roots[0].id, "root-a");
662 assert_eq!(clip.clip_roots[1].id, "root-b");
663 }
664
665 #[test]
666 fn cover_candidates_are_ordered_and_filtered() {
667 let clip = art_clip("L", "", "V");
668 assert_eq!(clip.cover_candidates(), vec!["L", "V"]);
669 }
670
671 #[test]
672 fn selected_image_url_prefers_large_then_image_then_video() {
673 assert_eq!(art_clip("L", "I", "V").selected_image_url(), Some("L"));
674 assert_eq!(art_clip("", "I", "V").selected_image_url(), Some("I"));
675 assert_eq!(art_clip("", "", "V").selected_image_url(), Some("V"));
676 assert_eq!(art_clip("", "", "").selected_image_url(), None);
677 }
678
679 #[test]
680 fn from_json_parses_all_lineage_metadata_fields() {
681 let raw = serde_json::json!({
682 "id": "self",
683 "title": "Lineage",
684 "is_trashed": true,
685 "metadata": {
686 "task": "extend",
687 "is_remix": true,
688 "cover_clip_id": "cover-1",
689 "upsample_clip_id": "upsample-2",
690 "remaster_clip_id": "remaster-3",
691 "speed_clip_id": "speed-4",
692 "override_history_clip_id": "ovh-5",
693 "override_future_clip_id": "ovf-6",
694 "history": [
695 {
696 "infill": false,
697 "id": "0a3c311a-hist",
698 "source": "ios",
699 "type": "gen",
700 "continue_at": 115.35
701 },
702 {
703 "infill": true,
704 "id": "infill-hist",
705 "source": "web",
706 "type": "gen",
707 "infill_start_s": 12.0,
708 "infill_end_s": 28.5,
709 "infill_lyrics": "new words here"
710 }
711 ],
712 "concat_history": [
713 {"infill": false, "id": "122d0d15-base", "continue_at": 131.5},
714 {"id": "cf7cb30f-part"}
715 ]
716 }
717 });
718
719 let clip = Clip::from_json(&raw);
720
721 assert_eq!(clip.task, "extend");
722 assert!(clip.is_remix);
723 assert!(clip.is_trashed);
724 assert_eq!(clip.cover_clip_id, "cover-1");
725 assert_eq!(clip.upsample_clip_id, "upsample-2");
726 assert_eq!(clip.remaster_clip_id, "remaster-3");
727 assert_eq!(clip.speed_clip_id, "speed-4");
728 assert_eq!(clip.override_history_clip_id, "ovh-5");
729 assert_eq!(clip.override_future_clip_id, "ovf-6");
730
731 assert_eq!(
732 clip.history,
733 vec![
734 HistoryEntry {
735 id: "0a3c311a-hist".to_owned(),
736 infill: false,
737 continue_at: Some(115.35),
738 ..Default::default()
739 },
740 HistoryEntry {
741 id: "infill-hist".to_owned(),
742 infill: true,
743 infill_start_s: Some(12.0),
744 infill_end_s: Some(28.5),
745 infill_lyrics: "new words here".to_owned(),
746 ..Default::default()
747 },
748 ]
749 );
750
751 assert_eq!(
752 clip.concat_history,
753 vec![
754 HistoryEntry {
755 id: "122d0d15-base".to_owned(),
756 continue_at: Some(131.5),
757 ..Default::default()
758 },
759 HistoryEntry {
760 id: "cf7cb30f-part".to_owned(),
761 ..Default::default()
762 },
763 ]
764 );
765 }
766
767 #[test]
768 fn bare_string_history_element_parses_to_id_only_entry() {
769 let raw = serde_json::json!({
770 "id": "self",
771 "metadata": {"history": ["m_bare-id-verbatim"]}
772 });
773
774 let clip = Clip::from_json(&raw);
775
776 assert_eq!(
777 clip.history,
778 vec![HistoryEntry {
779 id: "m_bare-id-verbatim".to_owned(),
780 ..Default::default()
781 }]
782 );
783 }
784
785 #[test]
786 fn play_count_parses_top_level_and_defaults_to_zero() {
787 let with_count = serde_json::json!({"id": "x", "play_count": 4242});
788 assert_eq!(Clip::from_json(&with_count).play_count, 4242);
789 assert_eq!(
791 Clip::from_json(&serde_json::json!({"id": "x"})).play_count,
792 0
793 );
794 assert_eq!(
795 Clip::from_json(&serde_json::json!({"id": "x", "play_count": null})).play_count,
796 0
797 );
798 }
799
800 #[test]
801 fn has_stem_parses_from_metadata_and_defaults_to_false() {
802 let with_stem = serde_json::json!({"id": "x", "metadata": {"has_stem": true}});
804 assert!(Clip::from_json(&with_stem).has_stem);
805 assert!(!Clip::from_json(&serde_json::json!({"id": "x"})).has_stem);
808 assert!(
809 !Clip::from_json(&serde_json::json!({"id": "x", "metadata": {"has_stem": null}}))
810 .has_stem
811 );
812 assert!(
813 !Clip::from_json(&serde_json::json!({"id": "x", "metadata": {"has_stem": false}}))
814 .has_stem
815 );
816 }
817
818 #[test]
819 fn stem_lineage_quartet_parses_from_metadata_float_tolerant() {
820 let raw = serde_json::json!({
823 "id": "stem-child",
824 "metadata": {
825 "has_stem": false,
826 "stem_from_id": "source-074",
827 "stem_task": "twelve",
828 "stem_type_id": 91.0,
829 "stem_type_group_name": "Backing_Vocals"
830 }
831 });
832 let clip = Clip::from_json(&raw);
833 assert_eq!(clip.stem_from_id, "source-074");
834 assert_eq!(clip.stem_task, "twelve");
835 assert_eq!(clip.stem_type_id, Some(91));
836 assert_eq!(clip.stem_type_group_name, "Backing_Vocals");
837 assert!(!clip.has_stem);
840
841 let as_int = serde_json::json!({"id": "x", "metadata": {"stem_type_id": 91}});
843 assert_eq!(Clip::from_json(&as_int).stem_type_id, Some(91));
844
845 let bare = Clip::from_json(&serde_json::json!({"id": "x"}));
849 assert_eq!(bare.stem_type_id, None);
850 assert_eq!(bare.stem_from_id, "");
851 assert_eq!(bare.stem_task, "");
852 assert_eq!(bare.stem_type_group_name, "");
853 for odd in [
854 serde_json::json!({"id": "x", "metadata": {"stem_type_id": null}}),
855 serde_json::json!({"id": "x", "metadata": {"stem_type_id": 91.5}}),
856 serde_json::json!({"id": "x", "metadata": {"stem_type_id": "91"}}),
857 ] {
858 assert_eq!(Clip::from_json(&odd).stem_type_id, None);
859 }
860 }
861
862 #[test]
863 fn absent_or_null_lineage_metadata_defaults_to_empty() {
864 let raw = serde_json::json!({
865 "id": "self",
866 "metadata": {
867 "cover_clip_id": null,
868 "is_remix": null,
869 "history": null
870 }
871 });
872
873 let clip = Clip::from_json(&raw);
874
875 assert_eq!(clip.task, "");
876 assert!(!clip.is_remix);
877 assert!(!clip.is_trashed);
878 assert_eq!(clip.cover_clip_id, "");
879 assert_eq!(clip.upsample_clip_id, "");
880 assert_eq!(clip.remaster_clip_id, "");
881 assert_eq!(clip.speed_clip_id, "");
882 assert_eq!(clip.override_history_clip_id, "");
883 assert_eq!(clip.override_future_clip_id, "");
884 assert!(clip.history.is_empty());
885 assert!(clip.concat_history.is_empty());
886 }
887}