Skip to main content

suno_core/wire/
clip.rs

1//! Clip decoding: the shared clip mapping used by the feed, playlist, clip,
2//! and stem endpoints, plus the small field readers it needs.
3
4use serde_json::Value;
5
6use crate::model::{Clip, ClipRoot, HistoryEntry, MediaUrl, cdn_audio_url};
7
8/// Parse a single-clip response body, accepting either a bare clip object or a
9/// `{"clip": {...}}` wrapper. Returns `None` when no clip id is present.
10pub(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
20/// Parse a `get_songs_by_ids` `{"clips":[…]}` body into clips with a non-empty
21/// id. Returns `None` when the body is not valid JSON or lacks a `clips` array,
22/// signalling the caller to fall back to per-id fetches. No downloadability
23/// filter is applied: these are lineage ancestors, which may be artefacts.
24pub(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
36/// Unwrap a `{ "clip": {...} }` wrapper to the inner clip object, or return
37/// `value` unchanged when it carries no object `clip` key (it is already bare).
38pub(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    /// Build a [`Clip`] from one raw API clip object.
47    ///
48    /// Clip-level fields and lineage live at the top level; content fields like
49    /// tags and duration live under `metadata`. Temporary `audiopipe` audio URLs
50    /// expire, so they are rewritten to the permanent CDN URL.
51    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
122/// Read a string field, defaulting to empty when missing or not a string.
123fn 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
131/// Read `primary`, falling back to `fallback` when it is missing or empty.
132///
133/// Suno exposes the owner's identity under bare keys on a feed clip
134/// (`display_name`, `handle`) but under `user_`-prefixed keys on a
135/// parent-shaped clip; this reads whichever shape is present.
136fn 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
145/// Read a bool field, defaulting to `false` when missing or not a bool.
146fn bool_field(value: &Value, key: &str) -> bool {
147    value.get(key).and_then(Value::as_bool).unwrap_or(false)
148}
149
150/// Read an integer field, tolerating the float form Suno's history uses (e.g.
151/// `stem_type_id` as `91.0`). Returns `None` when the field is missing,
152/// non-numeric, or a non-integral float.
153fn 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
163/// Read a CDN URL field, rewriting the unreliable `cdn2` host to `cdn1`.
164fn cdn(value: &Value, key: &str) -> String {
165    string(value, key).replace("cdn2.suno.ai", "cdn1.suno.ai")
166}
167
168/// Read the nested `clip_roots.clips[]` array into [`ClipRoot`]s.
169///
170/// The roots are nested under a `clip_roots` object (`{clips[],
171/// clip_attribution_type}`), NOT a top-level array, and each entry carries
172/// `user_`-prefixed identity keys. A missing key or non-array yields an empty
173/// `Vec`, so a clip without attribution roots degrades rather than fails.
174fn 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
193/// Read the top-level `media_urls` array into [`MediaUrl`]s.
194///
195/// A missing key or non-array yields an empty `Vec`, and each element defaults
196/// its fields, so a reshaped or partial entry degrades rather than fails.
197fn 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
212/// Read `value[key]` as an array of history records into [`HistoryEntry`]s.
213///
214/// Each element is mapped verbatim: a bare JSON string becomes an entry with
215/// only its `id` set, while an object supplies `id`, `infill`, `continue_at`,
216/// `infill_start_s`, `infill_end_s`, and `infill_lyrics`. Anything else (a
217/// missing key, a non-array, or an unexpected element type) yields an empty
218/// `Vec` or a defaulted entry, so parsing never fails.
219fn 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        // The mp3 entry carries no `encoding`, which must default to empty.
297        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        // A non-array media_urls degrades to empty, never a panic.
309        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        // The real /api/clip/{id} remix body: clip_roots is a NESTED object
316        // ({clips[], clip_attribution_type}), the root carries user_-prefixed
317        // identity keys, and the owner identity is top-level.
318        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        // The root's user_-prefixed identity keys map onto the flat fields.
353        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        // The cdn2 artwork host is rewritten to cdn1, as for the clip's own art.
357        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        // The reduced parent shape carries only user_-prefixed identity keys.
366        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        // When both shapes are present, the bare (feed) keys win.
383        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        // Absent clip_roots -> empty, attribution type empty.
398        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        // clip_roots present but `clips` missing or non-array -> empty, no panic.
403        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        // A top-level (non-object) clip_roots is ignored, never a panic.
417        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        // Absent or non-integer play_count falls back to zero.
554        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        // Present and true in metadata.
567        let with_stem = serde_json::json!({"id": "x", "metadata": {"has_stem": true}});
568        assert!(Clip::from_json(&with_stem).has_stem);
569        // Absent, null, or non-bool metadata.has_stem defaults to false, so a
570        // clip is never mistaken for a stem source without an explicit true.
571        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        // A stem child on an ordinary feed clip: the quartet is under metadata,
585        // and the history form of stem_type_id is a float (91.0).
586        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        // The quartet and has_stem are read from the same metadata block: a stem
602        // child carries the quartet yet is not itself a stem source.
603        assert!(!clip.has_stem);
604
605        // The plain integer form maps identically.
606        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        // Absent, null, non-integral, or non-numeric stem_type_id is None, and
610        // the string members default to empty, so a non-stem clip degrades
611        // cleanly rather than fabricating a separation id.
612        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}