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