1use serde_json::Value;
4
5use crate::consts::CDN_BASE_URL;
6
7#[derive(Debug, Clone, Default, PartialEq)]
9pub struct Clip {
10 pub id: String,
11 pub title: String,
12 pub audio_url: String,
13 pub image_url: String,
14 pub image_large_url: String,
15 pub video_url: String,
16 pub video_cover_url: String,
17 pub tags: String,
18 pub duration: f64,
19 pub play_count: u64,
20 pub status: String,
21 pub created_at: String,
22 pub display_name: String,
23 pub handle: String,
24 pub is_liked: bool,
25 pub is_trashed: bool,
26 pub has_vocal: bool,
27 pub has_stem: bool,
31 pub clip_type: String,
32 pub prompt: String,
33 pub gpt_description_prompt: String,
34 pub lyrics: String,
35 pub model_name: String,
36 pub major_model_version: String,
37 pub album_title: String,
38 pub root_ancestor_id: String,
39 pub lineage_status: String,
40 pub edited_clip_id: String,
41 pub task: String,
42 pub is_remix: bool,
43 pub cover_clip_id: String,
44 pub upsample_clip_id: String,
45 pub remaster_clip_id: String,
46 pub speed_clip_id: String,
47 pub override_history_clip_id: String,
48 pub override_future_clip_id: String,
49 pub history: Vec<HistoryEntry>,
50 pub concat_history: Vec<HistoryEntry>,
51}
52
53#[derive(Debug, Clone, Default, PartialEq)]
57pub struct HistoryEntry {
58 pub id: String,
59 pub infill: bool,
60 pub continue_at: Option<f64>,
61 pub infill_start_s: Option<f64>,
62 pub infill_end_s: Option<f64>,
63 pub infill_lyrics: String,
64}
65
66impl Clip {
67 pub fn from_json(raw: &Value) -> Clip {
73 let metadata = raw.get("metadata").cloned().unwrap_or(Value::Null);
74 let id = string(raw, "id");
75
76 let mut audio_url = string(raw, "audio_url");
77 if audio_url.contains("audiopipe") && !id.is_empty() {
78 audio_url = format!("{CDN_BASE_URL}/{id}.mp3");
79 }
80
81 let title = match raw.get("title") {
82 Some(Value::String(title)) => title.clone(),
83 _ => "Untitled".to_string(),
84 };
85
86 Clip {
87 id,
88 title,
89 audio_url,
90 image_url: cdn(raw, "image_url"),
91 image_large_url: cdn(raw, "image_large_url"),
92 video_url: cdn(raw, "video_url"),
93 video_cover_url: cdn(raw, "video_cover_url"),
94 tags: string(&metadata, "tags"),
95 duration: metadata
96 .get("duration")
97 .and_then(Value::as_f64)
98 .unwrap_or(0.0),
99 play_count: raw.get("play_count").and_then(Value::as_u64).unwrap_or(0),
100 status: raw
101 .get("status")
102 .and_then(Value::as_str)
103 .unwrap_or("unknown")
104 .to_string(),
105 created_at: string(raw, "created_at"),
106 display_name: string(raw, "display_name"),
107 handle: string(raw, "handle"),
108 is_liked: bool_field(raw, "is_liked"),
109 is_trashed: bool_field(raw, "is_trashed"),
110 has_vocal: bool_field(&metadata, "has_vocal"),
111 has_stem: bool_field(&metadata, "has_stem"),
112 clip_type: string(&metadata, "type"),
113 prompt: string(&metadata, "prompt"),
114 gpt_description_prompt: string(&metadata, "gpt_description_prompt"),
115 lyrics: string(raw, "lyrics"),
116 model_name: string(raw, "model_name"),
117 major_model_version: string(raw, "major_model_version"),
118 album_title: string(raw, "album_title"),
119 root_ancestor_id: string(raw, "root_ancestor_id"),
120 lineage_status: string(raw, "lineage_status"),
121 edited_clip_id: string(&metadata, "edited_clip_id"),
122 task: string(&metadata, "task"),
123 is_remix: bool_field(&metadata, "is_remix"),
124 cover_clip_id: string(&metadata, "cover_clip_id"),
125 upsample_clip_id: string(&metadata, "upsample_clip_id"),
126 remaster_clip_id: string(&metadata, "remaster_clip_id"),
127 speed_clip_id: string(&metadata, "speed_clip_id"),
128 override_history_clip_id: string(&metadata, "override_history_clip_id"),
129 override_future_clip_id: string(&metadata, "override_future_clip_id"),
130 history: history_entries(&metadata, "history"),
131 concat_history: history_entries(&metadata, "concat_history"),
132 }
133 }
134
135 pub fn mp3_url(&self) -> String {
138 if self.audio_url.is_empty() {
139 format!("{CDN_BASE_URL}/{}.mp3", self.id)
140 } else {
141 self.audio_url.clone()
142 }
143 }
144
145 pub fn cover_candidates(&self) -> Vec<&str> {
148 [
149 self.image_large_url.as_str(),
150 self.image_url.as_str(),
151 self.video_cover_url.as_str(),
152 ]
153 .into_iter()
154 .filter(|url| !url.is_empty())
155 .collect()
156 }
157
158 pub fn selected_image_url(&self) -> Option<&str> {
160 self.cover_candidates().into_iter().next()
161 }
162}
163
164fn string(value: &Value, key: &str) -> String {
166 value
167 .get(key)
168 .and_then(Value::as_str)
169 .unwrap_or("")
170 .to_string()
171}
172
173fn bool_field(value: &Value, key: &str) -> bool {
175 value.get(key).and_then(Value::as_bool).unwrap_or(false)
176}
177
178fn cdn(value: &Value, key: &str) -> String {
180 string(value, key).replace("cdn2.suno.ai", "cdn1.suno.ai")
181}
182
183fn history_entries(value: &Value, key: &str) -> Vec<HistoryEntry> {
191 let Some(Value::Array(items)) = value.get(key) else {
192 return Vec::new();
193 };
194 items
195 .iter()
196 .map(|item| match item {
197 Value::String(id) => HistoryEntry {
198 id: id.clone(),
199 ..HistoryEntry::default()
200 },
201 _ => HistoryEntry {
202 id: string(item, "id"),
203 infill: bool_field(item, "infill"),
204 continue_at: item.get("continue_at").and_then(Value::as_f64),
205 infill_start_s: item.get("infill_start_s").and_then(Value::as_f64),
206 infill_end_s: item.get("infill_end_s").and_then(Value::as_f64),
207 infill_lyrics: string(item, "infill_lyrics"),
208 },
209 })
210 .collect()
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 fn art_clip(image_large: &str, image: &str, video_cover: &str) -> Clip {
218 Clip {
219 image_large_url: image_large.to_owned(),
220 image_url: image.to_owned(),
221 video_cover_url: video_cover.to_owned(),
222 ..Default::default()
223 }
224 }
225
226 #[test]
227 fn mp3_url_uses_audio_url_or_synthesises_the_cdn_url() {
228 let mut clip = Clip {
229 id: "z".to_owned(),
230 audio_url: "https://x/real.mp3".to_owned(),
231 ..Default::default()
232 };
233 assert_eq!(clip.mp3_url(), "https://x/real.mp3");
234 clip.audio_url = String::new();
235 assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");
236 }
237
238 #[test]
239 fn cover_candidates_are_ordered_and_filtered() {
240 let clip = art_clip("L", "", "V");
241 assert_eq!(clip.cover_candidates(), vec!["L", "V"]);
242 }
243
244 #[test]
245 fn selected_image_url_prefers_large_then_image_then_video() {
246 assert_eq!(art_clip("L", "I", "V").selected_image_url(), Some("L"));
247 assert_eq!(art_clip("", "I", "V").selected_image_url(), Some("I"));
248 assert_eq!(art_clip("", "", "V").selected_image_url(), Some("V"));
249 assert_eq!(art_clip("", "", "").selected_image_url(), None);
250 }
251
252 #[test]
253 fn from_json_parses_all_lineage_metadata_fields() {
254 let raw = serde_json::json!({
255 "id": "self",
256 "title": "Lineage",
257 "is_trashed": true,
258 "metadata": {
259 "task": "extend",
260 "is_remix": true,
261 "cover_clip_id": "cover-1",
262 "upsample_clip_id": "upsample-2",
263 "remaster_clip_id": "remaster-3",
264 "speed_clip_id": "speed-4",
265 "override_history_clip_id": "ovh-5",
266 "override_future_clip_id": "ovf-6",
267 "history": [
268 {
269 "infill": false,
270 "id": "0a3c311a-hist",
271 "source": "ios",
272 "type": "gen",
273 "continue_at": 115.35
274 },
275 {
276 "infill": true,
277 "id": "infill-hist",
278 "source": "web",
279 "type": "gen",
280 "infill_start_s": 12.0,
281 "infill_end_s": 28.5,
282 "infill_lyrics": "new words here"
283 }
284 ],
285 "concat_history": [
286 {"infill": false, "id": "122d0d15-base", "continue_at": 131.5},
287 {"id": "cf7cb30f-part"}
288 ]
289 }
290 });
291
292 let clip = Clip::from_json(&raw);
293
294 assert_eq!(clip.task, "extend");
295 assert!(clip.is_remix);
296 assert!(clip.is_trashed);
297 assert_eq!(clip.cover_clip_id, "cover-1");
298 assert_eq!(clip.upsample_clip_id, "upsample-2");
299 assert_eq!(clip.remaster_clip_id, "remaster-3");
300 assert_eq!(clip.speed_clip_id, "speed-4");
301 assert_eq!(clip.override_history_clip_id, "ovh-5");
302 assert_eq!(clip.override_future_clip_id, "ovf-6");
303
304 assert_eq!(
305 clip.history,
306 vec![
307 HistoryEntry {
308 id: "0a3c311a-hist".to_owned(),
309 infill: false,
310 continue_at: Some(115.35),
311 ..Default::default()
312 },
313 HistoryEntry {
314 id: "infill-hist".to_owned(),
315 infill: true,
316 infill_start_s: Some(12.0),
317 infill_end_s: Some(28.5),
318 infill_lyrics: "new words here".to_owned(),
319 ..Default::default()
320 },
321 ]
322 );
323
324 assert_eq!(
325 clip.concat_history,
326 vec![
327 HistoryEntry {
328 id: "122d0d15-base".to_owned(),
329 continue_at: Some(131.5),
330 ..Default::default()
331 },
332 HistoryEntry {
333 id: "cf7cb30f-part".to_owned(),
334 ..Default::default()
335 },
336 ]
337 );
338 }
339
340 #[test]
341 fn bare_string_history_element_parses_to_id_only_entry() {
342 let raw = serde_json::json!({
343 "id": "self",
344 "metadata": {"history": ["m_bare-id-verbatim"]}
345 });
346
347 let clip = Clip::from_json(&raw);
348
349 assert_eq!(
350 clip.history,
351 vec![HistoryEntry {
352 id: "m_bare-id-verbatim".to_owned(),
353 ..Default::default()
354 }]
355 );
356 }
357
358 #[test]
359 fn play_count_parses_top_level_and_defaults_to_zero() {
360 let with_count = serde_json::json!({"id": "x", "play_count": 4242});
361 assert_eq!(Clip::from_json(&with_count).play_count, 4242);
362 assert_eq!(
364 Clip::from_json(&serde_json::json!({"id": "x"})).play_count,
365 0
366 );
367 assert_eq!(
368 Clip::from_json(&serde_json::json!({"id": "x", "play_count": null})).play_count,
369 0
370 );
371 }
372
373 #[test]
374 fn has_stem_parses_from_metadata_and_defaults_to_false() {
375 let with_stem = serde_json::json!({"id": "x", "metadata": {"has_stem": true}});
377 assert!(Clip::from_json(&with_stem).has_stem);
378 assert!(!Clip::from_json(&serde_json::json!({"id": "x"})).has_stem);
381 assert!(
382 !Clip::from_json(&serde_json::json!({"id": "x", "metadata": {"has_stem": null}}))
383 .has_stem
384 );
385 assert!(
386 !Clip::from_json(&serde_json::json!({"id": "x", "metadata": {"has_stem": false}}))
387 .has_stem
388 );
389 }
390
391 #[test]
392 fn absent_or_null_lineage_metadata_defaults_to_empty() {
393 let raw = serde_json::json!({
394 "id": "self",
395 "metadata": {
396 "cover_clip_id": null,
397 "is_remix": null,
398 "history": null
399 }
400 });
401
402 let clip = Clip::from_json(&raw);
403
404 assert_eq!(clip.task, "");
405 assert!(!clip.is_remix);
406 assert!(!clip.is_trashed);
407 assert_eq!(clip.cover_clip_id, "");
408 assert_eq!(clip.upsample_clip_id, "");
409 assert_eq!(clip.remaster_clip_id, "");
410 assert_eq!(clip.speed_clip_id, "");
411 assert_eq!(clip.override_history_clip_id, "");
412 assert_eq!(clip.override_future_clip_id, "");
413 assert!(clip.history.is_empty());
414 assert!(clip.concat_history.is_empty());
415 }
416}