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: bool_field(raw, "is_liked"),
105 is_trashed: bool_field(raw, "is_trashed"),
106 has_vocal: bool_field(&metadata, "has_vocal"),
107 clip_type: string(&metadata, "type"),
108 prompt: string(&metadata, "prompt"),
109 gpt_description_prompt: string(&metadata, "gpt_description_prompt"),
110 lyrics: string(raw, "lyrics"),
111 model_name: string(raw, "model_name"),
112 major_model_version: string(raw, "major_model_version"),
113 album_title: string(raw, "album_title"),
114 root_ancestor_id: string(raw, "root_ancestor_id"),
115 lineage_status: string(raw, "lineage_status"),
116 edited_clip_id: string(&metadata, "edited_clip_id"),
117 task: string(&metadata, "task"),
118 is_remix: bool_field(&metadata, "is_remix"),
119 cover_clip_id: string(&metadata, "cover_clip_id"),
120 upsample_clip_id: string(&metadata, "upsample_clip_id"),
121 remaster_clip_id: string(&metadata, "remaster_clip_id"),
122 speed_clip_id: string(&metadata, "speed_clip_id"),
123 override_history_clip_id: string(&metadata, "override_history_clip_id"),
124 override_future_clip_id: string(&metadata, "override_future_clip_id"),
125 history: history_entries(&metadata, "history"),
126 concat_history: history_entries(&metadata, "concat_history"),
127 }
128 }
129
130 pub fn mp3_url(&self) -> String {
133 if self.audio_url.is_empty() {
134 format!("{CDN_BASE_URL}/{}.mp3", self.id)
135 } else {
136 self.audio_url.clone()
137 }
138 }
139
140 pub fn cover_candidates(&self) -> Vec<&str> {
143 [
144 self.image_large_url.as_str(),
145 self.image_url.as_str(),
146 self.video_cover_url.as_str(),
147 ]
148 .into_iter()
149 .filter(|url| !url.is_empty())
150 .collect()
151 }
152
153 pub fn selected_image_url(&self) -> Option<&str> {
155 self.cover_candidates().into_iter().next()
156 }
157}
158
159fn string(value: &Value, key: &str) -> String {
161 value
162 .get(key)
163 .and_then(Value::as_str)
164 .unwrap_or("")
165 .to_string()
166}
167
168fn bool_field(value: &Value, key: &str) -> bool {
170 value.get(key).and_then(Value::as_bool).unwrap_or(false)
171}
172
173fn cdn(value: &Value, key: &str) -> String {
175 string(value, key).replace("cdn2.suno.ai", "cdn1.suno.ai")
176}
177
178fn history_entries(value: &Value, key: &str) -> Vec<HistoryEntry> {
186 let Some(Value::Array(items)) = value.get(key) else {
187 return Vec::new();
188 };
189 items
190 .iter()
191 .map(|item| match item {
192 Value::String(id) => HistoryEntry {
193 id: id.clone(),
194 ..HistoryEntry::default()
195 },
196 _ => HistoryEntry {
197 id: string(item, "id"),
198 infill: bool_field(item, "infill"),
199 continue_at: item.get("continue_at").and_then(Value::as_f64),
200 infill_start_s: item.get("infill_start_s").and_then(Value::as_f64),
201 infill_end_s: item.get("infill_end_s").and_then(Value::as_f64),
202 infill_lyrics: string(item, "infill_lyrics"),
203 },
204 })
205 .collect()
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 fn art_clip(image_large: &str, image: &str, video_cover: &str) -> Clip {
213 Clip {
214 image_large_url: image_large.to_owned(),
215 image_url: image.to_owned(),
216 video_cover_url: video_cover.to_owned(),
217 ..Default::default()
218 }
219 }
220
221 #[test]
222 fn mp3_url_uses_audio_url_or_synthesises_the_cdn_url() {
223 let mut clip = Clip {
224 id: "z".to_owned(),
225 audio_url: "https://x/real.mp3".to_owned(),
226 ..Default::default()
227 };
228 assert_eq!(clip.mp3_url(), "https://x/real.mp3");
229 clip.audio_url = String::new();
230 assert_eq!(clip.mp3_url(), "https://cdn1.suno.ai/z.mp3");
231 }
232
233 #[test]
234 fn cover_candidates_are_ordered_and_filtered() {
235 let clip = art_clip("L", "", "V");
236 assert_eq!(clip.cover_candidates(), vec!["L", "V"]);
237 }
238
239 #[test]
240 fn selected_image_url_prefers_large_then_image_then_video() {
241 assert_eq!(art_clip("L", "I", "V").selected_image_url(), Some("L"));
242 assert_eq!(art_clip("", "I", "V").selected_image_url(), Some("I"));
243 assert_eq!(art_clip("", "", "V").selected_image_url(), Some("V"));
244 assert_eq!(art_clip("", "", "").selected_image_url(), None);
245 }
246
247 #[test]
248 fn from_json_parses_all_lineage_metadata_fields() {
249 let raw = serde_json::json!({
250 "id": "self",
251 "title": "Lineage",
252 "is_trashed": true,
253 "metadata": {
254 "task": "extend",
255 "is_remix": true,
256 "cover_clip_id": "cover-1",
257 "upsample_clip_id": "upsample-2",
258 "remaster_clip_id": "remaster-3",
259 "speed_clip_id": "speed-4",
260 "override_history_clip_id": "ovh-5",
261 "override_future_clip_id": "ovf-6",
262 "history": [
263 {
264 "infill": false,
265 "id": "0a3c311a-hist",
266 "source": "ios",
267 "type": "gen",
268 "continue_at": 115.35
269 },
270 {
271 "infill": true,
272 "id": "infill-hist",
273 "source": "web",
274 "type": "gen",
275 "infill_start_s": 12.0,
276 "infill_end_s": 28.5,
277 "infill_lyrics": "new words here"
278 }
279 ],
280 "concat_history": [
281 {"infill": false, "id": "122d0d15-base", "continue_at": 131.5},
282 {"id": "cf7cb30f-part"}
283 ]
284 }
285 });
286
287 let clip = Clip::from_json(&raw);
288
289 assert_eq!(clip.task, "extend");
290 assert!(clip.is_remix);
291 assert!(clip.is_trashed);
292 assert_eq!(clip.cover_clip_id, "cover-1");
293 assert_eq!(clip.upsample_clip_id, "upsample-2");
294 assert_eq!(clip.remaster_clip_id, "remaster-3");
295 assert_eq!(clip.speed_clip_id, "speed-4");
296 assert_eq!(clip.override_history_clip_id, "ovh-5");
297 assert_eq!(clip.override_future_clip_id, "ovf-6");
298
299 assert_eq!(
300 clip.history,
301 vec![
302 HistoryEntry {
303 id: "0a3c311a-hist".to_owned(),
304 infill: false,
305 continue_at: Some(115.35),
306 ..Default::default()
307 },
308 HistoryEntry {
309 id: "infill-hist".to_owned(),
310 infill: true,
311 infill_start_s: Some(12.0),
312 infill_end_s: Some(28.5),
313 infill_lyrics: "new words here".to_owned(),
314 ..Default::default()
315 },
316 ]
317 );
318
319 assert_eq!(
320 clip.concat_history,
321 vec![
322 HistoryEntry {
323 id: "122d0d15-base".to_owned(),
324 continue_at: Some(131.5),
325 ..Default::default()
326 },
327 HistoryEntry {
328 id: "cf7cb30f-part".to_owned(),
329 ..Default::default()
330 },
331 ]
332 );
333 }
334
335 #[test]
336 fn bare_string_history_element_parses_to_id_only_entry() {
337 let raw = serde_json::json!({
338 "id": "self",
339 "metadata": {"history": ["m_bare-id-verbatim"]}
340 });
341
342 let clip = Clip::from_json(&raw);
343
344 assert_eq!(
345 clip.history,
346 vec![HistoryEntry {
347 id: "m_bare-id-verbatim".to_owned(),
348 ..Default::default()
349 }]
350 );
351 }
352
353 #[test]
354 fn play_count_parses_top_level_and_defaults_to_zero() {
355 let with_count = serde_json::json!({"id": "x", "play_count": 4242});
356 assert_eq!(Clip::from_json(&with_count).play_count, 4242);
357 assert_eq!(
359 Clip::from_json(&serde_json::json!({"id": "x"})).play_count,
360 0
361 );
362 assert_eq!(
363 Clip::from_json(&serde_json::json!({"id": "x", "play_count": null})).play_count,
364 0
365 );
366 }
367
368 #[test]
369 fn absent_or_null_lineage_metadata_defaults_to_empty() {
370 let raw = serde_json::json!({
371 "id": "self",
372 "metadata": {
373 "cover_clip_id": null,
374 "is_remix": null,
375 "history": null
376 }
377 });
378
379 let clip = Clip::from_json(&raw);
380
381 assert_eq!(clip.task, "");
382 assert!(!clip.is_remix);
383 assert!(!clip.is_trashed);
384 assert_eq!(clip.cover_clip_id, "");
385 assert_eq!(clip.upsample_clip_id, "");
386 assert_eq!(clip.remaster_clip_id, "");
387 assert_eq!(clip.speed_clip_id, "");
388 assert_eq!(clip.override_history_clip_id, "");
389 assert_eq!(clip.override_future_clip_id, "");
390 assert!(clip.history.is_empty());
391 assert!(clip.concat_history.is_empty());
392 }
393}