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> {
240 [self.image_large_url.as_str(), self.image_url.as_str()]
241 .into_iter()
242 .filter(|url| !url.is_empty())
243 .collect()
244 }
245
246 pub fn selected_image_url(&self) -> Option<&str> {
255 if !self.image_large_url.is_empty() {
256 Some(self.image_large_url.as_str())
257 } else if !self.image_url.is_empty() {
258 Some(self.image_url.as_str())
259 } else {
260 None
261 }
262 }
263}
264
265fn string(value: &Value, key: &str) -> String {
267 value
268 .get(key)
269 .and_then(Value::as_str)
270 .unwrap_or("")
271 .to_string()
272}
273
274fn string_or(value: &Value, primary: &str, fallback: &str) -> String {
280 let first = string(value, primary);
281 if first.is_empty() {
282 string(value, fallback)
283 } else {
284 first
285 }
286}
287
288fn bool_field(value: &Value, key: &str) -> bool {
290 value.get(key).and_then(Value::as_bool).unwrap_or(false)
291}
292
293fn int_tolerant(value: &Value, key: &str) -> Option<i64> {
297 let field = value.get(key)?;
298 field.as_i64().or_else(|| {
299 field
300 .as_f64()
301 .filter(|number| number.fract() == 0.0)
302 .map(|number| number as i64)
303 })
304}
305
306fn cdn(value: &Value, key: &str) -> String {
308 string(value, key).replace("cdn2.suno.ai", "cdn1.suno.ai")
309}
310
311fn cdn_audio_url(url: &str, id: &str) -> String {
317 if url.contains("audiopipe") && !id.is_empty() {
318 format!("{CDN_BASE_URL}/{id}.mp3")
319 } else {
320 url.to_string()
321 }
322}
323
324fn parse_clip_roots(raw: &Value) -> Vec<ClipRoot> {
331 let Some(Value::Array(items)) = raw.get("clip_roots").and_then(|roots| roots.get("clips"))
332 else {
333 return Vec::new();
334 };
335 items
336 .iter()
337 .map(|item| ClipRoot {
338 id: string(item, "id"),
339 title: string(item, "title"),
340 image_url: cdn(item, "image_url"),
341 is_public: bool_field(item, "is_public"),
342 display_name: string(item, "user_display_name"),
343 handle: string(item, "user_handle"),
344 avatar_image_url: string(item, "user_avatar_image_url"),
345 })
346 .collect()
347}
348
349fn parse_media_urls(raw: &Value) -> Vec<MediaUrl> {
354 let Some(Value::Array(items)) = raw.get("media_urls") else {
355 return Vec::new();
356 };
357 items
358 .iter()
359 .map(|item| MediaUrl {
360 url: string(item, "url"),
361 content_type: string(item, "content_type"),
362 delivery: string(item, "delivery"),
363 encoding: string(item, "encoding"),
364 })
365 .collect()
366}
367
368fn history_entries(value: &Value, key: &str) -> Vec<HistoryEntry> {
376 let Some(Value::Array(items)) = value.get(key) else {
377 return Vec::new();
378 };
379 items
380 .iter()
381 .map(|item| match item {
382 Value::String(id) => HistoryEntry {
383 id: id.clone(),
384 ..HistoryEntry::default()
385 },
386 _ => HistoryEntry {
387 id: string(item, "id"),
388 infill: bool_field(item, "infill"),
389 continue_at: item.get("continue_at").and_then(Value::as_f64),
390 infill_start_s: item.get("infill_start_s").and_then(Value::as_f64),
391 infill_end_s: item.get("infill_end_s").and_then(Value::as_f64),
392 infill_lyrics: string(item, "infill_lyrics"),
393 },
394 })
395 .collect()
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 fn art_clip(image_large: &str, image: &str, video_cover: &str) -> Clip {
403 Clip {
404 image_large_url: image_large.to_owned(),
405 image_url: image.to_owned(),
406 video_cover_url: video_cover.to_owned(),
407 ..Default::default()
408 }
409 }
410
411 #[test]
412 fn mp3_url_uses_audio_url_or_synthesises_the_cdn_url() {
413 let mut clip = Clip {
414 id: "z".to_owned(),
415 audio_url: "https://x/real.mp3".to_owned(),
416 ..Default::default()
417 };
418 assert_eq!(clip.mp3_url(), "https://x/real.mp3");
419 clip.audio_url = String::new();
420 assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");
421 }
422
423 #[test]
424 fn mp3_url_prefers_the_media_urls_mp3_then_audio_url_then_synthesis() {
425 let clip = Clip {
427 id: "z".to_owned(),
428 audio_url: "https://x/real.mp3".to_owned(),
429 media_urls: vec![
430 MediaUrl {
431 url: "https://media/z.m4a".to_owned(),
432 content_type: "m4a-opus".to_owned(),
433 delivery: "progressive".to_owned(),
434 encoding: "1.0.0".to_owned(),
435 },
436 MediaUrl {
437 url: "https://cdn1.suno.ai/z.mp3".to_owned(),
438 content_type: "mp3".to_owned(),
439 delivery: "progressive".to_owned(),
440 encoding: String::new(),
441 },
442 ],
443 ..Default::default()
444 };
445 assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");
446
447 let no_media = Clip {
449 id: "z".to_owned(),
450 audio_url: "https://x/real.mp3".to_owned(),
451 ..Default::default()
452 };
453 assert_eq!(no_media.mp3_url(), "https://x/real.mp3");
454
455 let only_m4a = Clip {
457 id: "z".to_owned(),
458 audio_url: String::new(),
459 media_urls: vec![MediaUrl {
460 url: "https://media/z.m4a".to_owned(),
461 content_type: "m4a-opus".to_owned(),
462 ..Default::default()
463 }],
464 ..Default::default()
465 };
466 assert_eq!(only_m4a.mp3_url(), "https://cdn1.suno.ai/z.mp3");
467 }
468
469 #[test]
470 fn mp3_url_rewrites_an_expiring_audiopipe_media_url() {
471 let expiring = Clip {
474 id: "z".to_owned(),
475 media_urls: vec![MediaUrl {
476 url: "https://audiopipe.suno.ai/item?id=z".to_owned(),
477 content_type: "mp3".to_owned(),
478 ..Default::default()
479 }],
480 ..Default::default()
481 };
482 assert_eq!(expiring.mp3_url(), "https://cdn1.suno.ai/z.mp3");
483
484 let permanent = Clip {
486 id: "z".to_owned(),
487 media_urls: vec![MediaUrl {
488 url: "https://cdn1.suno.ai/z.mp3".to_owned(),
489 content_type: "mp3".to_owned(),
490 ..Default::default()
491 }],
492 ..Default::default()
493 };
494 assert_eq!(permanent.mp3_url(), "https://cdn1.suno.ai/z.mp3");
495 }
496
497 #[test]
498 fn from_json_reads_media_urls_user_id_and_batch_index() {
499 let raw = serde_json::json!({
500 "id": "clip-1",
501 "user_id": "owner-9",
502 "batch_index": 23,
503 "media_urls": [
504 {
505 "url": "https://media/clip-1.m4a",
506 "content_type": "m4a-opus",
507 "delivery": "progressive",
508 "encoding": "1.0.0"
509 },
510 {
511 "url": "https://cdn1.suno.ai/clip-1.mp3",
512 "content_type": "mp3",
513 "delivery": "progressive"
514 }
515 ]
516 });
517
518 let clip = Clip::from_json(&raw);
519
520 assert_eq!(clip.user_id, "owner-9");
521 assert_eq!(clip.batch_index, Some(23));
522 assert_eq!(clip.media_urls.len(), 2);
523 assert_eq!(clip.media_urls[0].content_type, "m4a-opus");
524 assert_eq!(clip.media_urls[0].encoding, "1.0.0");
525 assert_eq!(clip.media_urls[1].content_type, "mp3");
527 assert_eq!(clip.media_urls[1].encoding, "");
528 assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/clip-1.mp3");
529 }
530
531 #[test]
532 fn from_json_defaults_media_urls_user_id_and_batch_index_when_absent() {
533 let clip = Clip::from_json(&serde_json::json!({"id": "clip-1"}));
534 assert!(clip.media_urls.is_empty());
535 assert_eq!(clip.user_id, "");
536 assert_eq!(clip.batch_index, None);
537 let odd = Clip::from_json(&serde_json::json!({"id": "x", "media_urls": "nope"}));
539 assert!(odd.media_urls.is_empty());
540 }
541
542 #[test]
543 fn from_json_parses_nested_clip_roots_and_owner_identity() {
544 let raw = serde_json::json!({
548 "id": "00000000-0000-4000-8000-000000000017",
549 "title": "Track 1",
550 "user_id": "00000000-0000-4000-8000-000000000019",
551 "display_name": "Example Artist 4",
552 "handle": "example-artist-1",
553 "avatar_image_url": "https://cdn1.suno.ai/avatar.jpg",
554 "batch_index": 1,
555 "clip_roots": {
556 "clips": [
557 {
558 "id": "00000000-0000-4000-8000-000000000020",
559 "title": "Track 2",
560 "image_url": "https://cdn2.suno.ai/image_00000000-0000-4000-8000-000000000020.jpeg",
561 "is_public": false,
562 "user_display_name": "Example Artist 4",
563 "user_handle": "example-artist-1",
564 "user_avatar_image_url": "https://cdn1.suno.ai/avatar.jpg"
565 }
566 ],
567 "clip_attribution_type": "remix"
568 }
569 });
570 let clip = Clip::from_json(&raw);
571
572 assert_eq!(clip.display_name, "Example Artist 4");
573 assert_eq!(clip.handle, "example-artist-1");
574 assert_eq!(clip.avatar_image_url, "https://cdn1.suno.ai/avatar.jpg");
575 assert_eq!(clip.clip_attribution_type, "remix");
576 assert_eq!(clip.clip_roots.len(), 1);
577 let root = &clip.clip_roots[0];
578 assert_eq!(root.id, "00000000-0000-4000-8000-000000000020");
579 assert_eq!(root.title, "Track 2");
580 assert!(!root.is_public);
581 assert_eq!(root.display_name, "Example Artist 4");
583 assert_eq!(root.handle, "example-artist-1");
584 assert_eq!(root.avatar_image_url, "https://cdn1.suno.ai/avatar.jpg");
585 assert_eq!(
587 root.image_url,
588 "https://cdn1.suno.ai/image_00000000-0000-4000-8000-000000000020.jpeg"
589 );
590 }
591
592 #[test]
593 fn from_json_reads_user_prefixed_identity_on_a_parent_shape() {
594 let raw = serde_json::json!({
596 "id": "00000000-0000-4000-8000-000000000020",
597 "title": "Track 2",
598 "is_public": false,
599 "user_display_name": "Example Artist 4",
600 "user_handle": "example-artist-1",
601 "user_avatar_image_url": "https://cdn1.suno.ai/avatar.jpg"
602 });
603 let clip = Clip::from_json(&raw);
604 assert_eq!(clip.display_name, "Example Artist 4");
605 assert_eq!(clip.handle, "example-artist-1");
606 assert_eq!(clip.avatar_image_url, "https://cdn1.suno.ai/avatar.jpg");
607 }
608
609 #[test]
610 fn from_json_prefers_bare_identity_over_user_prefixed() {
611 let raw = serde_json::json!({
613 "id": "x",
614 "display_name": "Bare Name",
615 "user_display_name": "Prefixed Name",
616 "handle": "bare-handle",
617 "user_handle": "prefixed-handle"
618 });
619 let clip = Clip::from_json(&raw);
620 assert_eq!(clip.display_name, "Bare Name");
621 assert_eq!(clip.handle, "bare-handle");
622 }
623
624 #[test]
625 fn from_json_defaults_clip_roots_when_absent_or_malformed() {
626 let none = Clip::from_json(&serde_json::json!({"id": "x"}));
628 assert!(none.clip_roots.is_empty());
629 assert_eq!(none.clip_attribution_type, "");
630
631 let no_clips = Clip::from_json(&serde_json::json!({
633 "id": "x",
634 "clip_roots": {"clip_attribution_type": "remix"}
635 }));
636 assert!(no_clips.clip_roots.is_empty());
637 assert_eq!(no_clips.clip_attribution_type, "remix");
638
639 let odd = Clip::from_json(&serde_json::json!({
640 "id": "x",
641 "clip_roots": {"clips": "nope"}
642 }));
643 assert!(odd.clip_roots.is_empty());
644
645 let array_shape = Clip::from_json(&serde_json::json!({
647 "id": "x",
648 "clip_roots": [{"id": "r"}]
649 }));
650 assert!(array_shape.clip_roots.is_empty());
651 assert_eq!(array_shape.clip_attribution_type, "");
652 }
653
654 #[test]
655 fn from_json_reads_multiple_clip_roots_in_order() {
656 let raw = serde_json::json!({
657 "id": "x",
658 "clip_roots": {
659 "clips": [
660 {"id": "root-a", "title": "A"},
661 {"id": "root-b", "title": "B"}
662 ],
663 "clip_attribution_type": "remix"
664 }
665 });
666 let clip = Clip::from_json(&raw);
667 assert_eq!(clip.clip_roots.len(), 2);
668 assert_eq!(clip.clip_roots[0].id, "root-a");
669 assert_eq!(clip.clip_roots[1].id, "root-b");
670 }
671
672 #[test]
673 fn cover_candidates_are_static_images_ordered_and_filtered() {
674 assert_eq!(art_clip("L", "I", "V").cover_candidates(), vec!["L", "I"]);
677 assert_eq!(art_clip("L", "", "V").cover_candidates(), vec!["L"]);
678 assert!(art_clip("", "", "V").cover_candidates().is_empty());
679 }
680
681 #[test]
682 fn selected_image_url_prefers_large_then_image_and_excludes_video() {
683 assert_eq!(art_clip("L", "I", "V").selected_image_url(), Some("L"));
684 assert_eq!(art_clip("", "I", "V").selected_image_url(), Some("I"));
685 assert_eq!(art_clip("", "", "V").selected_image_url(), None);
687 assert_eq!(art_clip("", "", "").selected_image_url(), None);
688 }
689
690 #[test]
691 fn from_json_parses_all_lineage_metadata_fields() {
692 let raw = serde_json::json!({
693 "id": "self",
694 "title": "Lineage",
695 "is_trashed": true,
696 "metadata": {
697 "task": "extend",
698 "is_remix": true,
699 "cover_clip_id": "cover-1",
700 "upsample_clip_id": "upsample-2",
701 "remaster_clip_id": "remaster-3",
702 "speed_clip_id": "speed-4",
703 "override_history_clip_id": "ovh-5",
704 "override_future_clip_id": "ovf-6",
705 "history": [
706 {
707 "infill": false,
708 "id": "0a3c311a-hist",
709 "source": "ios",
710 "type": "gen",
711 "continue_at": 115.35
712 },
713 {
714 "infill": true,
715 "id": "infill-hist",
716 "source": "web",
717 "type": "gen",
718 "infill_start_s": 12.0,
719 "infill_end_s": 28.5,
720 "infill_lyrics": "new words here"
721 }
722 ],
723 "concat_history": [
724 {"infill": false, "id": "122d0d15-base", "continue_at": 131.5},
725 {"id": "cf7cb30f-part"}
726 ]
727 }
728 });
729
730 let clip = Clip::from_json(&raw);
731
732 assert_eq!(clip.task, "extend");
733 assert!(clip.is_remix);
734 assert!(clip.is_trashed);
735 assert_eq!(clip.cover_clip_id, "cover-1");
736 assert_eq!(clip.upsample_clip_id, "upsample-2");
737 assert_eq!(clip.remaster_clip_id, "remaster-3");
738 assert_eq!(clip.speed_clip_id, "speed-4");
739 assert_eq!(clip.override_history_clip_id, "ovh-5");
740 assert_eq!(clip.override_future_clip_id, "ovf-6");
741
742 assert_eq!(
743 clip.history,
744 vec![
745 HistoryEntry {
746 id: "0a3c311a-hist".to_owned(),
747 infill: false,
748 continue_at: Some(115.35),
749 ..Default::default()
750 },
751 HistoryEntry {
752 id: "infill-hist".to_owned(),
753 infill: true,
754 infill_start_s: Some(12.0),
755 infill_end_s: Some(28.5),
756 infill_lyrics: "new words here".to_owned(),
757 ..Default::default()
758 },
759 ]
760 );
761
762 assert_eq!(
763 clip.concat_history,
764 vec![
765 HistoryEntry {
766 id: "122d0d15-base".to_owned(),
767 continue_at: Some(131.5),
768 ..Default::default()
769 },
770 HistoryEntry {
771 id: "cf7cb30f-part".to_owned(),
772 ..Default::default()
773 },
774 ]
775 );
776 }
777
778 #[test]
779 fn bare_string_history_element_parses_to_id_only_entry() {
780 let raw = serde_json::json!({
781 "id": "self",
782 "metadata": {"history": ["m_bare-id-verbatim"]}
783 });
784
785 let clip = Clip::from_json(&raw);
786
787 assert_eq!(
788 clip.history,
789 vec![HistoryEntry {
790 id: "m_bare-id-verbatim".to_owned(),
791 ..Default::default()
792 }]
793 );
794 }
795
796 #[test]
797 fn play_count_parses_top_level_and_defaults_to_zero() {
798 let with_count = serde_json::json!({"id": "x", "play_count": 4242});
799 assert_eq!(Clip::from_json(&with_count).play_count, 4242);
800 assert_eq!(
802 Clip::from_json(&serde_json::json!({"id": "x"})).play_count,
803 0
804 );
805 assert_eq!(
806 Clip::from_json(&serde_json::json!({"id": "x", "play_count": null})).play_count,
807 0
808 );
809 }
810
811 #[test]
812 fn has_stem_parses_from_metadata_and_defaults_to_false() {
813 let with_stem = serde_json::json!({"id": "x", "metadata": {"has_stem": true}});
815 assert!(Clip::from_json(&with_stem).has_stem);
816 assert!(!Clip::from_json(&serde_json::json!({"id": "x"})).has_stem);
819 assert!(
820 !Clip::from_json(&serde_json::json!({"id": "x", "metadata": {"has_stem": null}}))
821 .has_stem
822 );
823 assert!(
824 !Clip::from_json(&serde_json::json!({"id": "x", "metadata": {"has_stem": false}}))
825 .has_stem
826 );
827 }
828
829 #[test]
830 fn stem_lineage_quartet_parses_from_metadata_float_tolerant() {
831 let raw = serde_json::json!({
834 "id": "stem-child",
835 "metadata": {
836 "has_stem": false,
837 "stem_from_id": "source-074",
838 "stem_task": "twelve",
839 "stem_type_id": 91.0,
840 "stem_type_group_name": "Backing_Vocals"
841 }
842 });
843 let clip = Clip::from_json(&raw);
844 assert_eq!(clip.stem_from_id, "source-074");
845 assert_eq!(clip.stem_task, "twelve");
846 assert_eq!(clip.stem_type_id, Some(91));
847 assert_eq!(clip.stem_type_group_name, "Backing_Vocals");
848 assert!(!clip.has_stem);
851
852 let as_int = serde_json::json!({"id": "x", "metadata": {"stem_type_id": 91}});
854 assert_eq!(Clip::from_json(&as_int).stem_type_id, Some(91));
855
856 let bare = Clip::from_json(&serde_json::json!({"id": "x"}));
860 assert_eq!(bare.stem_type_id, None);
861 assert_eq!(bare.stem_from_id, "");
862 assert_eq!(bare.stem_task, "");
863 assert_eq!(bare.stem_type_group_name, "");
864 for odd in [
865 serde_json::json!({"id": "x", "metadata": {"stem_type_id": null}}),
866 serde_json::json!({"id": "x", "metadata": {"stem_type_id": 91.5}}),
867 serde_json::json!({"id": "x", "metadata": {"stem_type_id": "91"}}),
868 ] {
869 assert_eq!(Clip::from_json(&odd).stem_type_id, None);
870 }
871 }
872
873 #[test]
874 fn absent_or_null_lineage_metadata_defaults_to_empty() {
875 let raw = serde_json::json!({
876 "id": "self",
877 "metadata": {
878 "cover_clip_id": null,
879 "is_remix": null,
880 "history": null
881 }
882 });
883
884 let clip = Clip::from_json(&raw);
885
886 assert_eq!(clip.task, "");
887 assert!(!clip.is_remix);
888 assert!(!clip.is_trashed);
889 assert_eq!(clip.cover_clip_id, "");
890 assert_eq!(clip.upsample_clip_id, "");
891 assert_eq!(clip.remaster_clip_id, "");
892 assert_eq!(clip.speed_clip_id, "");
893 assert_eq!(clip.override_history_clip_id, "");
894 assert_eq!(clip.override_future_clip_id, "");
895 assert!(clip.history.is_empty());
896 assert!(clip.concat_history.is_empty());
897 }
898}