Skip to main content

suno_core/
model.rs

1//! The [`Clip`] domain model and its mapping from the Suno API JSON shape.
2
3use serde_json::Value;
4
5use crate::consts::CDN_BASE_URL;
6
7/// One finished Suno track, flattened from the API's nested response shape.
8#[derive(Debug, Clone, Default, PartialEq)]
9pub struct Clip {
10    pub id: String,
11    pub title: String,
12    pub audio_url: String,
13    /// Every audio asset Suno lists for the clip (an `mp3` plus, usually, an
14    /// `m4a-opus`). Empty when the API omits `media_urls`, so a clip with no
15    /// listed assets falls back to `audio_url` (then synthesis) exactly as
16    /// before. The `mp3` entry is the authoritative, non-expiring source.
17    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    /// The clip owner's account id (top-level `user_id`). Feeds the
30    /// foreign-owner attribution check and cross-account dedup; empty when the
31    /// API omits it.
32    pub user_id: String,
33    /// Index within a generation batch (paired gens), for sibling
34    /// disambiguation in naming and dedup. `None` when `batch_index` is absent.
35    pub batch_index: Option<i64>,
36    /// The clip owner's avatar image URL (`avatar_image_url`, or the
37    /// `user_`-prefixed form on a parent-shaped clip). Empty when absent.
38    pub avatar_image_url: String,
39    pub is_liked: bool,
40    pub is_trashed: bool,
41    pub has_vocal: bool,
42    /// Whether Suno reports this clip already has separated stems, from
43    /// `metadata.has_stem`. The stems mirror uses it as a precondition: a clip
44    /// whose `has_stem` is false or absent is never queried for stems.
45    pub has_stem: bool,
46    /// `metadata.stem_from_id`: the clip this one was separated from, when it is
47    /// a stem child. Empty when absent. Structured stem lineage, carried on an
48    /// ordinary feed clip independently of the `/stems` listing.
49    pub stem_from_id: String,
50    /// `metadata.stem_task`: the separation-run id grouping one set of stems.
51    /// Empty when absent.
52    pub stem_task: String,
53    /// `metadata.stem_type_id`: the numeric separation-type id. Tolerates both
54    /// the integer and the float (`91.0`) forms Suno has used; `None` when
55    /// absent or non-numeric.
56    pub stem_type_id: Option<i64>,
57    /// `metadata.stem_type_group_name`: the canonical stem group in underscore
58    /// form (e.g. `Backing_Vocals`). Empty when absent. Preferred, normalised,
59    /// over a title parenthetical as the stem label.
60    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    /// The remix/attribution origins Suno lists under the nested `clip_roots`
79    /// object (`clip_roots.clips[]`). Empty when the key is absent. These feed
80    /// attribution edges and a same-owner gap-fill seed only; they are never
81    /// read by structural root resolution.
82    pub clip_roots: Vec<ClipRoot>,
83    /// The attribution kind for `clip_roots` (`clip_roots.clip_attribution_type`,
84    /// e.g. `"remix"`). Open string, empty when absent.
85    pub clip_attribution_type: String,
86}
87
88/// One remix/attribution origin from a clip's nested `clip_roots.clips[]` list.
89///
90/// Informational lineage the API exposes directly on the clip: the clip was
91/// derived from this root. Identity keys are `user_`-prefixed here. Every field
92/// defaults to empty/false when absent, so a reshaped or partial entry degrades
93/// rather than fails.
94#[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/// One audio asset from a clip's top-level `media_urls` list.
106///
107/// Suno lists each downloadable rendition (an `mp3`, and usually an
108/// `m4a-opus`) with its `content_type`, `delivery` mode, and an optional
109/// `encoding` version (only the m4a-opus carries one). Every field defaults to
110/// empty when absent, so a reshaped or partial entry degrades rather than
111/// fails.
112#[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/// One entry in a clip's `history` or `concat_history`, mirroring the API's
121/// per-segment lineage record. Ids are stored verbatim (any `m_` prefix is left
122/// for the resolver to strip).
123#[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    /// Build a [`Clip`] from one raw API clip object.
135    ///
136    /// Clip-level fields and lineage live at the top level; content fields like
137    /// tags and duration live under `metadata`. Temporary `audiopipe` audio URLs
138    /// expire, so they are rewritten to the permanent CDN URL.
139    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    /// The MP3 source URL, in priority order: the API-listed `media_urls` `mp3`
210    /// asset (authoritative and non-expiring), then the clip's `audio_url`, then
211    /// the deterministic CDN URL synthesised from the id.
212    ///
213    /// When `media_urls` is absent the behaviour is unchanged: a present
214    /// `audio_url` is returned verbatim, and an empty one synthesises the CDN
215    /// URL.
216    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    /// Static cover-art image URLs in preference order (large image, then
232    /// image), dropping any that are empty.
233    ///
234    /// The `video_cover_url` preview is deliberately excluded: it is an MP4, not
235    /// an embeddable still image, so embedding it as a JPEG/WebP picture would
236    /// corrupt the artwork. A clip with only a video preview therefore yields no
237    /// embeddable cover (the animated cover is handled separately, by embedding a
238    /// transcoded WebP).
239    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    /// The preferred static cover-art image URL, or `None` when the clip carries
247    /// no still image.
248    ///
249    /// Like [`cover_candidates`](Self::cover_candidates), the `video_cover_url`
250    /// preview (an MP4) is deliberately excluded: this drives the static `.jpg`
251    /// sidecars, the album `folder.jpg`, and the embedded-cover identity hash,
252    /// none of which can use a video. A clip with only a video preview yields
253    /// `None`.
254    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
265/// Read a string field, defaulting to empty when missing or not a string.
266fn 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
274/// Read `primary`, falling back to `fallback` when it is missing or empty.
275///
276/// Suno exposes the owner's identity under bare keys on a feed clip
277/// (`display_name`, `handle`) but under `user_`-prefixed keys on a
278/// parent-shaped clip; this reads whichever shape is present.
279fn 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
288/// Read a bool field, defaulting to `false` when missing or not a bool.
289fn bool_field(value: &Value, key: &str) -> bool {
290    value.get(key).and_then(Value::as_bool).unwrap_or(false)
291}
292
293/// Read an integer field, tolerating the float form Suno's history uses (e.g.
294/// `stem_type_id` as `91.0`). Returns `None` when the field is missing,
295/// non-numeric, or a non-integral float.
296fn 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
306/// Read a CDN URL field, rewriting the unreliable `cdn2` host to `cdn1`.
307fn cdn(value: &Value, key: &str) -> String {
308    string(value, key).replace("cdn2.suno.ai", "cdn1.suno.ai")
309}
310
311/// Rewrite an expiring `audiopipe` audio URL to the permanent CDN URL for `id`.
312/// Any other URL, including an empty one, is returned unchanged, and an empty
313/// `id` leaves the URL untouched because the CDN URL cannot be synthesised
314/// without it. Shared by `audio_url` mapping and `mp3_url` so no single URL
315/// source can leak an expiring link.
316fn 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
324/// Read the nested `clip_roots.clips[]` array into [`ClipRoot`]s.
325///
326/// The roots are nested under a `clip_roots` object (`{clips[],
327/// clip_attribution_type}`), NOT a top-level array, and each entry carries
328/// `user_`-prefixed identity keys. A missing key or non-array yields an empty
329/// `Vec`, so a clip without attribution roots degrades rather than fails.
330fn 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
349/// Read the top-level `media_urls` array into [`MediaUrl`]s.
350///
351/// A missing key or non-array yields an empty `Vec`, and each element defaults
352/// its fields, so a reshaped or partial entry degrades rather than fails.
353fn 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
368/// Read `value[key]` as an array of history records into [`HistoryEntry`]s.
369///
370/// Each element is mapped verbatim: a bare JSON string becomes an entry with
371/// only its `id` set, while an object supplies `id`, `infill`, `continue_at`,
372/// `infill_start_s`, `infill_end_s`, and `infill_lyrics`. Anything else (a
373/// missing key, a non-array, or an unexpected element type) yields an empty
374/// `Vec` or a defaulted entry, so parsing never fails.
375fn 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        // The API-listed mp3 asset wins over audio_url.
426        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        // Absent media_urls falls back to audio_url unchanged (today's behaviour).
448        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        // A media_urls set with only a non-mp3 asset still falls back.
456        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        // An audiopipe mp3 in media_urls expires, so mp3_url rewrites it to the
472        // permanent CDN URL, matching how audio_url is rewritten at parse time.
473        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        // A permanent (non-audiopipe) mp3 asset is returned verbatim.
485        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        // The mp3 entry carries no `encoding`, which must default to empty.
526        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        // A non-array media_urls degrades to empty, never a panic.
538        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        // The real /api/clip/{id} remix body: clip_roots is a NESTED object
545        // ({clips[], clip_attribution_type}), the root carries user_-prefixed
546        // identity keys, and the owner identity is top-level.
547        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        // The root's user_-prefixed identity keys map onto the flat fields.
582        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        // The cdn2 artwork host is rewritten to cdn1, as for the clip's own art.
586        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        // The reduced parent shape carries only user_-prefixed identity keys.
595        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        // When both shapes are present, the bare (feed) keys win.
612        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        // Absent clip_roots -> empty, attribution type empty.
627        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        // clip_roots present but `clips` missing or non-array -> empty, no panic.
632        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        // A top-level (non-object) clip_roots is ignored, never a panic.
646        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        // The video preview (an MP4) is never an embeddable still, so it is
675        // excluded; only the static image URLs remain, in preference order.
676        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        // A video-only clip has no still image to embed or write as a `.jpg`.
686        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        // Absent or non-integer play_count falls back to zero.
801        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        // Present and true in metadata.
814        let with_stem = serde_json::json!({"id": "x", "metadata": {"has_stem": true}});
815        assert!(Clip::from_json(&with_stem).has_stem);
816        // Absent, null, or non-bool metadata.has_stem defaults to false, so a
817        // clip is never mistaken for a stem source without an explicit true.
818        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        // A stem child on an ordinary feed clip: the quartet is under metadata,
832        // and the history form of stem_type_id is a float (91.0).
833        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        // The quartet and has_stem are read from the same metadata block: a stem
849        // child carries the quartet yet is not itself a stem source.
850        assert!(!clip.has_stem);
851
852        // The plain integer form maps identically.
853        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        // Absent, null, non-integral, or non-numeric stem_type_id is None, and
857        // the string members default to empty, so a non-stem clip degrades
858        // cleanly rather than fabricating a separation id.
859        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}