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    /// Cover-art URLs in preference order (large image, image, video cover),
232    /// dropping any that are empty.
233    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    /// The preferred cover-art URL, or `None` when the clip carries no art.
245    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
258/// Read a string field, defaulting to empty when missing or not a string.
259fn 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
267/// Read `primary`, falling back to `fallback` when it is missing or empty.
268///
269/// Suno exposes the owner's identity under bare keys on a feed clip
270/// (`display_name`, `handle`) but under `user_`-prefixed keys on a
271/// parent-shaped clip; this reads whichever shape is present.
272fn 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
281/// Read a bool field, defaulting to `false` when missing or not a bool.
282fn bool_field(value: &Value, key: &str) -> bool {
283    value.get(key).and_then(Value::as_bool).unwrap_or(false)
284}
285
286/// Read an integer field, tolerating the float form Suno's history uses (e.g.
287/// `stem_type_id` as `91.0`). Returns `None` when the field is missing,
288/// non-numeric, or a non-integral float.
289fn 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
299/// Read a CDN URL field, rewriting the unreliable `cdn2` host to `cdn1`.
300fn cdn(value: &Value, key: &str) -> String {
301    string(value, key).replace("cdn2.suno.ai", "cdn1.suno.ai")
302}
303
304/// Rewrite an expiring `audiopipe` audio URL to the permanent CDN URL for `id`.
305/// Any other URL, including an empty one, is returned unchanged, and an empty
306/// `id` leaves the URL untouched because the CDN URL cannot be synthesised
307/// without it. Shared by `audio_url` mapping and `mp3_url` so no single URL
308/// source can leak an expiring link.
309fn 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
317/// Read the nested `clip_roots.clips[]` array into [`ClipRoot`]s.
318///
319/// The roots are nested under a `clip_roots` object (`{clips[],
320/// clip_attribution_type}`), NOT a top-level array, and each entry carries
321/// `user_`-prefixed identity keys. A missing key or non-array yields an empty
322/// `Vec`, so a clip without attribution roots degrades rather than fails.
323fn 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
342/// Read the top-level `media_urls` array into [`MediaUrl`]s.
343///
344/// A missing key or non-array yields an empty `Vec`, and each element defaults
345/// its fields, so a reshaped or partial entry degrades rather than fails.
346fn 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
361/// Read `value[key]` as an array of history records into [`HistoryEntry`]s.
362///
363/// Each element is mapped verbatim: a bare JSON string becomes an entry with
364/// only its `id` set, while an object supplies `id`, `infill`, `continue_at`,
365/// `infill_start_s`, `infill_end_s`, and `infill_lyrics`. Anything else (a
366/// missing key, a non-array, or an unexpected element type) yields an empty
367/// `Vec` or a defaulted entry, so parsing never fails.
368fn 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        // The API-listed mp3 asset wins over audio_url.
419        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        // Absent media_urls falls back to audio_url unchanged (today's behaviour).
441        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        // A media_urls set with only a non-mp3 asset still falls back.
449        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        // An audiopipe mp3 in media_urls expires, so mp3_url rewrites it to the
465        // permanent CDN URL, matching how audio_url is rewritten at parse time.
466        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        // A permanent (non-audiopipe) mp3 asset is returned verbatim.
478        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        // The mp3 entry carries no `encoding`, which must default to empty.
519        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        // A non-array media_urls degrades to empty, never a panic.
531        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        // The real /api/clip/{id} remix body: clip_roots is a NESTED object
538        // ({clips[], clip_attribution_type}), the root carries user_-prefixed
539        // identity keys, and the owner identity is top-level.
540        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        // The root's user_-prefixed identity keys map onto the flat fields.
575        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        // The cdn2 artwork host is rewritten to cdn1, as for the clip's own art.
579        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        // The reduced parent shape carries only user_-prefixed identity keys.
588        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        // When both shapes are present, the bare (feed) keys win.
605        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        // Absent clip_roots -> empty, attribution type empty.
620        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        // clip_roots present but `clips` missing or non-array -> empty, no panic.
625        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        // A top-level (non-object) clip_roots is ignored, never a panic.
639        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        // Absent or non-integer play_count falls back to zero.
790        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        // Present and true in metadata.
803        let with_stem = serde_json::json!({"id": "x", "metadata": {"has_stem": true}});
804        assert!(Clip::from_json(&with_stem).has_stem);
805        // Absent, null, or non-bool metadata.has_stem defaults to false, so a
806        // clip is never mistaken for a stem source without an explicit true.
807        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        // A stem child on an ordinary feed clip: the quartet is under metadata,
821        // and the history form of stem_type_id is a float (91.0).
822        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        // The quartet and has_stem are read from the same metadata block: a stem
838        // child carries the quartet yet is not itself a stem source.
839        assert!(!clip.has_stem);
840
841        // The plain integer form maps identically.
842        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        // Absent, null, non-integral, or non-numeric stem_type_id is None, and
846        // the string members default to empty, so a non-stem clip degrades
847        // cleanly rather than fabricating a separation id.
848        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}