1use std::collections::BTreeSet;
4
5use serde_json::Value;
6
7use crate::auth::ClerkAuth;
8use crate::backoff::{backoff_delay, retry_after};
9use crate::clock::Clock;
10use crate::consts::{
11 API_MAX_RETRIES, CLIP_PARENT_PATH, FEED_PAGE_DELAY, FEED_PAGE_SIZE, FEED_V3_PATH, MAX_PAGES,
12 PLAYLIST_ME_PATH, PLAYLIST_PATH, SUNO_API_BASE_URL,
13};
14use crate::error::{Error, Result};
15use crate::http::{Http, HttpRequest, Method};
16use crate::is_downloadable;
17use crate::model::Clip;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Playlist {
27 pub id: String,
29 pub name: String,
31 pub num_clips: u64,
33}
34
35pub struct SunoClient<C> {
42 auth: ClerkAuth,
43 clock: C,
44}
45
46impl<C: Clock> SunoClient<C> {
47 pub fn new(auth: ClerkAuth, clock: C) -> Self {
49 Self { auth, clock }
50 }
51
52 pub fn auth(&self) -> &ClerkAuth {
54 &self.auth
55 }
56
57 pub async fn list_clips(
73 &mut self,
74 http: &impl Http,
75 liked: bool,
76 limit: Option<usize>,
77 ) -> Result<(Vec<Clip>, bool)> {
78 let mut clips = Vec::new();
79 let mut cursor: Option<String> = None;
80 let mut complete = false;
81 for page in 0..MAX_PAGES {
82 if page > 0 {
83 self.clock.sleep(FEED_PAGE_DELAY).await;
84 }
85 let body = feed_v3_body(liked, cursor.as_deref());
86 let response = self
87 .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
88 .await?;
89 let (page_clips, has_more, next_cursor) = parse_feed_v3(&response)?;
90 clips.extend(page_clips);
91 match has_more {
92 Some(false) => {
93 complete = true;
94 break;
95 }
96 Some(true) => match next_cursor {
97 Some(next) => cursor = Some(next),
98 None => break,
99 },
100 None => break,
101 }
102 if limit.is_some_and(|n| clips.len() >= n) {
103 break;
104 }
105 }
106 if let Some(n) = limit {
107 clips.truncate(n);
108 }
109 Ok((clips, complete))
110 }
111
112 pub async fn get_clip(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
118 if let Some(clip) = self.try_get_clip(http, id).await? {
119 return Ok(clip);
120 }
121 self.find_in_feed(http, id).await
122 }
123
124 pub async fn request_wav(&mut self, http: &impl Http, id: &str) -> Result<()> {
126 let path = format!("/api/gen/{id}/convert_wav/");
127 self.api_request(http, Method::Post, &path, Vec::new())
128 .await?;
129 Ok(())
130 }
131
132 pub async fn wav_url(&mut self, http: &impl Http, id: &str) -> Result<Option<String>> {
134 let path = format!("/api/gen/{id}/wav_file/");
135 let body = self.api_get(http, &path).await?;
136 let data: Value = serde_json::from_slice(&body)
137 .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
138 Ok(data
139 .get("wav_file_url")
140 .and_then(Value::as_str)
141 .filter(|url| !url.is_empty())
142 .map(str::to_string))
143 }
144
145 pub async fn get_clips_by_ids(&mut self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
158 let mut clips = Vec::new();
159 let mut seen: BTreeSet<&str> = BTreeSet::new();
160 for id in ids {
161 if id.is_empty() || !seen.insert(id) {
162 continue;
163 }
164 let path = format!("/api/clip/{id}");
165 match self.api_get_retrying(http, &path).await {
166 Ok(body) => {
167 if let Some(clip) = parse_clip(&body) {
168 clips.push(clip);
169 }
170 }
171 Err(Error::NotFound(_)) => continue,
172 Err(err) => return Err(err),
173 }
174 }
175 Ok(clips)
176 }
177
178 pub async fn get_clip_parent(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
186 let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
187 match self.api_get_retrying(http, &path).await {
188 Ok(body) => Ok(parse_clip(&body)),
189 Err(Error::NotFound(_)) => Ok(None),
190 Err(err) => Err(err),
191 }
192 }
193
194 pub async fn get_playlists(&mut self, http: &impl Http) -> Result<Vec<Playlist>> {
205 let mut playlists = Vec::new();
206 for page in 1..=MAX_PAGES {
207 if page > 1 {
208 self.clock.sleep(FEED_PAGE_DELAY).await;
209 }
210 let path =
211 format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
212 let body = self.api_get_retrying(http, &path).await?;
213 let page_playlists = parse_playlists(&body)?;
214 if page_playlists.is_empty() {
215 break;
216 }
217 playlists.extend(page_playlists);
218 }
219 Ok(playlists)
220 }
221
222 pub async fn get_playlist_clips(&mut self, http: &impl Http, id: &str) -> Result<Vec<Clip>> {
230 let path = format!("{PLAYLIST_PATH}{id}/");
231 let body = self.api_get_retrying(http, &path).await?;
232 parse_playlist_clips(&body)
233 }
234
235 async fn try_get_clip(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
238 let path = format!("/api/clip/{id}");
239 match self.api_get_retrying(http, &path).await {
240 Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
241 Err(Error::NotFound(_)) => Ok(None),
242 Err(err) => Err(err),
243 }
244 }
245
246 async fn find_in_feed(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
248 let (clips, _complete) = self.list_clips(http, false, None).await?;
249 clips
250 .into_iter()
251 .find(|clip| clip.id == id)
252 .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
253 }
254
255 async fn api_get(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
257 self.api_request(http, Method::Get, path, Vec::new()).await
258 }
259
260 async fn api_get_retrying(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
262 self.api_send_retrying(http, Method::Get, path, Vec::new())
263 .await
264 }
265
266 async fn api_send_retrying(
278 &mut self,
279 http: &impl Http,
280 method: Method,
281 path: &str,
282 body: Vec<u8>,
283 ) -> Result<Vec<u8>> {
284 let mut retries = 0;
285 loop {
286 match self.api_request(http, method, path, body.clone()).await {
287 Ok(response) => return Ok(response),
288 Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
289 self.clock.sleep(backoff_delay(retries, retry_after)).await;
290 retries += 1;
291 }
292 Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
293 self.clock.sleep(backoff_delay(retries, None)).await;
294 retries += 1;
295 }
296 Err(err) => return Err(err),
297 }
298 }
299 }
300
301 async fn api_request(
306 &mut self,
307 http: &impl Http,
308 method: Method,
309 path: &str,
310 body: Vec<u8>,
311 ) -> Result<Vec<u8>> {
312 let url = format!("{SUNO_API_BASE_URL}{path}");
313 let mut auth_refreshed = false;
314 loop {
315 let jwt = self.auth.ensure_jwt(http).await?;
316 let mut request = match method {
317 Method::Get => HttpRequest::get(url.clone()),
318 Method::Post => HttpRequest::post(url.clone(), body.clone()),
319 };
320 request
321 .headers
322 .push(("Authorization".to_string(), format!("Bearer {jwt}")));
323 let response = http
324 .send(request)
325 .await
326 .map_err(|err| Error::Connection(err.to_string()))?;
327 match response.status {
328 200..=299 => return Ok(response.body),
329 401 | 403 if !auth_refreshed => {
330 self.auth.invalidate_jwt();
331 auth_refreshed = true;
332 }
333 401 | 403 => {
334 return Err(Error::Auth(format!(
335 "Suno API auth failed with status {}",
336 response.status
337 )));
338 }
339 429 => {
340 return Err(Error::RateLimited {
341 retry_after: retry_after(&response),
342 });
343 }
344 404 => {
345 return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
346 }
347 status => {
348 let preview: String = String::from_utf8_lossy(&response.body)
349 .chars()
350 .take(200)
351 .collect();
352 return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
353 }
354 }
355 }
356 }
357}
358
359fn parse_clip(body: &[u8]) -> Option<Clip> {
362 let data: Value = serde_json::from_slice(body).ok()?;
363 let raw = data
364 .get("clip")
365 .filter(|value| value.is_object())
366 .unwrap_or(&data);
367 let has_id = raw
368 .get("id")
369 .and_then(Value::as_str)
370 .is_some_and(|id| !id.is_empty());
371 has_id.then(|| Clip::from_json(raw))
372}
373
374fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
381 let mut filters = serde_json::Map::new();
382 filters.insert("trashed".to_string(), Value::String("False".to_string()));
383 if liked {
384 filters.insert("liked".to_string(), Value::String("True".to_string()));
385 }
386 let mut body = serde_json::Map::new();
387 body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
388 body.insert("filters".to_string(), Value::Object(filters));
389 if let Some(cursor) = cursor {
390 body.insert("cursor".to_string(), Value::String(cursor.to_string()));
391 }
392 serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
393}
394
395fn parse_feed_v3(body: &[u8]) -> Result<(Vec<Clip>, Option<bool>, Option<String>)> {
402 let data: Value = serde_json::from_slice(body)
403 .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
404 let Some(object) = data.as_object() else {
405 return Ok((Vec::new(), None, None));
406 };
407 let clips = object
408 .get("clips")
409 .and_then(Value::as_array)
410 .map(|raw| {
411 raw.iter()
412 .map(Clip::from_json)
413 .filter(is_downloadable)
414 .collect()
415 })
416 .unwrap_or_default();
417 let has_more = object.get("has_more").and_then(Value::as_bool);
418 let next_cursor = object
419 .get("next_cursor")
420 .and_then(Value::as_str)
421 .filter(|cursor| !cursor.is_empty())
422 .map(str::to_string);
423 Ok((clips, has_more, next_cursor))
424}
425
426fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
428 let data: Value = serde_json::from_slice(body)
429 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
430 Ok(data
431 .get("playlists")
432 .and_then(Value::as_array)
433 .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
434 .unwrap_or_default())
435}
436
437fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
442 let id = raw
443 .get("id")
444 .and_then(Value::as_str)
445 .filter(|id| !id.is_empty())?
446 .to_string();
447 let name = match raw.get("name") {
448 Some(Value::String(name)) if !name.is_empty() => name.clone(),
449 _ => "Untitled".to_string(),
450 };
451 let num_clips = raw
452 .get("num_total_results")
453 .and_then(Value::as_u64)
454 .unwrap_or(0);
455 Some(Playlist {
456 id,
457 name,
458 num_clips,
459 })
460}
461
462fn parse_playlist_clips(body: &[u8]) -> Result<Vec<Clip>> {
472 let data: Value = serde_json::from_slice(body)
473 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
474 Ok(data
475 .get("playlist_clips")
476 .and_then(Value::as_array)
477 .map(|raw| {
478 raw.iter()
479 .map(|entry| {
480 let clip = entry
481 .get("clip")
482 .filter(|value| value.is_object())
483 .unwrap_or(entry);
484 Clip::from_json(clip)
485 })
486 .filter(|clip| !clip.id.is_empty())
487 .collect()
488 })
489 .unwrap_or_default())
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
496 use std::time::Duration;
497
498 fn feed_body() -> String {
499 serde_json::json!({
500 "has_more": false,
501 "clips": [
502 {
503 "id": "a", "title": "Song A", "status": "complete",
504 "audio_url": "https://cdn1.suno.ai/a.mp3",
505 "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
506 },
507 {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
508 {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
509 {
510 "id": "d", "title": "Context", "status": "complete",
511 "metadata": {"type": "rendered_context_window"}
512 }
513 ]
514 })
515 .to_string()
516 }
517
518 #[test]
519 fn parse_feed_v3_filters_and_reads_pagination() {
520 let (clips, has_more, next_cursor) = parse_feed_v3(feed_body().as_bytes()).unwrap();
521 assert_eq!(has_more, Some(false));
522 assert_eq!(next_cursor, None);
523 assert_eq!(clips.len(), 1);
524 assert_eq!(clips[0].id, "a");
525 assert_eq!(clips[0].tags, "rock");
526 assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
527 }
528
529 #[test]
530 fn feed_v3_body_carries_filters_and_optional_cursor() {
531 let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
532 assert_eq!(first["filters"]["trashed"], "False");
533 assert!(first.get("cursor").is_none());
534 assert!(first["filters"].get("liked").is_none());
535
536 let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
537 assert_eq!(liked["filters"]["liked"], "True");
538 assert_eq!(liked["cursor"], "cur42");
539 }
540
541 #[test]
542 fn audiopipe_url_is_rewritten_to_cdn() {
543 let raw =
544 serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
545 assert_eq!(
546 Clip::from_json(&raw).audio_url,
547 "https://cdn1.suno.ai/x.mp3"
548 );
549 }
550
551 #[test]
552 fn list_clips_authenticates_then_reads_the_feed() {
553 let client_body = serde_json::json!({
554 "response": {
555 "last_active_session_id": "s",
556 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
557 }
558 })
559 .to_string();
560 let http = MockHttp::new(vec![
561 Rule::new(
562 "/v1/client/sessions/",
563 200,
564 r#"{"jwt": "a.b.c"}"#.to_string(),
565 ),
566 Rule::new("/v1/client", 200, client_body),
567 Rule::new("/api/feed/v3", 200, feed_body()),
568 ]);
569
570 let mut auth = ClerkAuth::new("eyJtoken");
571 pollster::block_on(auth.authenticate(&http)).unwrap();
572 let mut client = SunoClient::new(auth, RecordingClock::new());
573 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
574 assert_eq!(clips.len(), 1);
575 assert_eq!(clips[0].id, "a");
576 assert!(complete);
577 }
578
579 #[test]
580 fn list_clips_reports_incomplete_when_paging_is_capped() {
581 let mut rules = auth_rules();
582 rules.push(Rule::new(
583 "/api/feed/v3",
584 200,
585 serde_json::json!({
586 "has_more": true,
587 "next_cursor": "cur1",
588 "clips": [{
589 "id": "a", "title": "Song A", "status": "complete",
590 "audio_url": "https://cdn1.suno.ai/a.mp3",
591 "metadata": {"type": "gen"}
592 }]
593 })
594 .to_string(),
595 ));
596 let http = MockHttp::new(rules);
597 let mut client = authed_client(&http);
598
599 let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
600 assert!(!complete);
601 }
602
603 fn auth_rules() -> Vec<Rule> {
604 let client_body = serde_json::json!({
605 "response": {
606 "last_active_session_id": "s",
607 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
608 }
609 })
610 .to_string();
611 vec![
612 Rule::new(
613 "/v1/client/sessions/",
614 200,
615 r#"{"jwt": "a.b.c"}"#.to_string(),
616 ),
617 Rule::new("/v1/client", 200, client_body),
618 ]
619 }
620
621 fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
622 let mut auth = ClerkAuth::new("eyJtoken");
623 pollster::block_on(auth.authenticate(http)).unwrap();
624 SunoClient::new(auth, RecordingClock::new())
625 }
626
627 fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
628 let mut auth = ClerkAuth::new("eyJtoken");
629 pollster::block_on(auth.authenticate(http)).unwrap();
630 SunoClient::new(auth, clock)
631 }
632
633 fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
634 let mut page = serde_json::json!({
635 "has_more": next_cursor.is_some(),
636 "clips": [{
637 "id": id, "title": "Song", "status": "complete",
638 "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
639 "metadata": {"type": "gen"}
640 }]
641 });
642 if let Some(cursor) = next_cursor {
643 page["next_cursor"] = serde_json::json!(cursor);
644 }
645 page.to_string()
646 }
647
648 #[test]
649 fn list_clips_retries_a_rate_limited_page() {
650 let http = ScriptedHttp::new().with_auth().route_seq(
651 "/api/feed/v3",
652 vec![Reply::status(429), Reply::json(&feed_body())],
653 );
654 let clock = RecordingClock::new();
655 let mut client = scripted_client(&http, clock.clone());
656
657 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
658 assert_eq!(clips.len(), 1);
659 assert!(complete);
660 assert_eq!(http.count("/api/feed/v3"), 2);
662 assert_eq!(clock.sleeps(), vec![Duration::from_secs(1)]);
663 }
664
665 #[test]
666 fn list_clips_honours_retry_after_on_a_throttled_page() {
667 let http = ScriptedHttp::new().with_auth().route_seq(
668 "/api/feed/v3",
669 vec![
670 Reply::status(429).with_retry_after(7),
671 Reply::json(&feed_body()),
672 ],
673 );
674 let clock = RecordingClock::new();
675 let mut client = scripted_client(&http, clock.clone());
676
677 let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
678 assert_eq!(clips.len(), 1);
679 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
681 }
682
683 #[test]
684 fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
685 let http = ScriptedHttp::new().with_auth().route_seq(
687 "/api/feed/v3",
688 vec![
689 Reply::json(&one_clip_page("a", Some("cur1"))),
690 Reply::status(429),
691 Reply::json(&one_clip_page("b", None)),
692 ],
693 );
694 let clock = RecordingClock::new();
695 let mut client = scripted_client(&http, clock.clone());
696
697 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
698 assert!(complete);
699 assert_eq!(clips.len(), 2);
700 let bodies = http.bodies();
701 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
702 assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
703 let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
706 let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
707 assert_eq!(retried["cursor"], "cur1");
708 assert_eq!(after_retry["cursor"], "cur1");
709 }
710
711 #[test]
712 fn list_clips_threads_the_cursor_across_pages() {
713 let http = ScriptedHttp::new().with_auth().route_seq(
714 "/api/feed/v3",
715 vec![
716 Reply::json(&one_clip_page("a", Some("cur1"))),
717 Reply::json(&one_clip_page("b", None)),
718 ],
719 );
720 let clock = RecordingClock::new();
721 let mut client = scripted_client(&http, clock.clone());
722
723 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
724 assert!(complete);
725 assert_eq!(clips.len(), 2);
726 let bodies = http.bodies();
727 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
728 assert_eq!(feed_bodies.len(), 2);
729 let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
730 let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
731 assert!(page1.get("cursor").is_none());
733 assert_eq!(page2["cursor"], "cur1");
734 }
735
736 #[test]
737 fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
738 let page = serde_json::json!({
741 "has_more": true,
742 "clips": [{
743 "id": "a", "title": "Song", "status": "complete",
744 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
745 }]
746 })
747 .to_string();
748 let http = ScriptedHttp::new()
749 .with_auth()
750 .route("/api/feed/v3", Reply::json(&page));
751 let clock = RecordingClock::new();
752 let mut client = scripted_client(&http, clock.clone());
753
754 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
755 assert!(!complete);
756 assert_eq!(clips.len(), 1);
757 assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
758 }
759
760 #[test]
761 fn list_clips_is_incomplete_when_has_more_is_missing() {
762 let page = serde_json::json!({
764 "clips": [{
765 "id": "a", "title": "Song", "status": "complete",
766 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
767 }]
768 })
769 .to_string();
770 let http = ScriptedHttp::new()
771 .with_auth()
772 .route("/api/feed/v3", Reply::json(&page));
773 let clock = RecordingClock::new();
774 let mut client = scripted_client(&http, clock.clone());
775
776 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
777 assert!(!complete);
778 assert_eq!(clips.len(), 1);
779 assert_eq!(http.count("/api/feed/v3"), 1);
780 }
781
782 #[test]
783 fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
784 let http = ScriptedHttp::new().with_auth().route_seq(
785 "/api/feed/v3",
786 vec![
787 Reply::json(&one_clip_page("a", Some("cur1"))),
788 Reply::status(500),
789 ],
790 );
791 let clock = RecordingClock::new();
792 let mut client = scripted_client(&http, clock.clone());
793
794 let result = pollster::block_on(client.list_clips(&http, false, None));
795 assert!(matches!(result, Err(Error::Api(_))));
796 }
797
798 #[test]
799 fn list_clips_is_complete_on_an_empty_drained_feed() {
800 let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
803 let http = ScriptedHttp::new()
804 .with_auth()
805 .route("/api/feed/v3", Reply::json(&page));
806 let clock = RecordingClock::new();
807 let mut client = scripted_client(&http, clock.clone());
808
809 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
810 assert!(complete);
811 assert!(clips.is_empty());
812 }
813
814 #[test]
815 fn list_clips_liked_scope_sends_the_liked_filter() {
816 let http = ScriptedHttp::new()
817 .with_auth()
818 .route("/api/feed/v3", Reply::json(&feed_body()));
819 let clock = RecordingClock::new();
820 let mut client = scripted_client(&http, clock.clone());
821
822 let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
823 let bodies = http.bodies();
824 let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
825 let value: Value = serde_json::from_str(feed_body).unwrap();
826 assert_eq!(value["filters"]["liked"], "True");
827 assert_eq!(value["filters"]["trashed"], "False");
828 }
829
830 #[test]
831 fn list_clips_paces_between_pages() {
832 let http = ScriptedHttp::new().with_auth().route_seq(
833 "/api/feed/v3",
834 vec![
835 Reply::json(&one_clip_page("a", Some("cur1"))),
836 Reply::json(&one_clip_page("e", None)),
837 ],
838 );
839 let clock = RecordingClock::new();
840 let mut client = scripted_client(&http, clock.clone());
841
842 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
843 assert!(complete);
844 assert_eq!(clips.len(), 2);
845 assert_eq!(http.count("/api/feed/v3"), 2);
846 assert_eq!(clock.sleeps(), vec![crate::consts::FEED_PAGE_DELAY]);
848 }
849
850 #[test]
851 fn list_clips_gives_up_after_max_retries() {
852 let http = ScriptedHttp::new()
853 .with_auth()
854 .route("/api/feed/v3", Reply::status(429));
855 let clock = RecordingClock::new();
856 let mut client = scripted_client(&http, clock.clone());
857
858 let result = pollster::block_on(client.list_clips(&http, false, None));
859 assert!(matches!(result, Err(Error::RateLimited { .. })));
860 let budget = crate::consts::API_MAX_RETRIES as usize;
861 assert_eq!(clock.sleeps().len(), budget);
862 assert_eq!(http.count("/api/feed/v3"), budget + 1);
863 }
864
865 #[test]
866 fn parse_clip_accepts_bare_and_wrapped_shapes() {
867 let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
868 assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
869
870 let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
871 assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
872
873 let missing = serde_json::json!({"detail": "not found"}).to_string();
874 assert!(parse_clip(missing.as_bytes()).is_none());
875 }
876
877 #[test]
878 fn get_clip_uses_the_dedicated_endpoint() {
879 let clip_body = serde_json::json!({
880 "id": "z", "title": "Zed", "status": "complete",
881 "audio_url": "https://cdn1.suno.ai/z.mp3",
882 "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
883 })
884 .to_string();
885 let mut rules = auth_rules();
886 rules.push(Rule::new("/api/clip/", 200, clip_body));
887 let http = MockHttp::new(rules);
888 let mut client = authed_client(&http);
889
890 let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
891 assert_eq!(clip.id, "z");
892 assert_eq!(clip.title, "Zed");
893 assert_eq!(clip.tags, "jazz");
894 }
895
896 #[test]
897 fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
898 let mut rules = auth_rules();
899 rules.push(Rule::new(
900 "/api/clip/",
901 404,
902 r#"{"detail": "not found"}"#.to_string(),
903 ));
904 rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
905 let http = MockHttp::new(rules);
906 let mut client = authed_client(&http);
907
908 let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
909 assert_eq!(clip.id, "a");
910 assert_eq!(clip.tags, "rock");
911 }
912
913 #[test]
914 fn request_wav_accepts_a_2xx_status() {
915 let mut rules = auth_rules();
916 rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
917 let http = MockHttp::new(rules);
918 let mut client = authed_client(&http);
919
920 assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
921 }
922
923 #[test]
924 fn wav_url_reads_the_ready_url() {
925 let mut rules = auth_rules();
926 rules.push(Rule::new(
927 "/wav_file/",
928 200,
929 r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
930 ));
931 let http = MockHttp::new(rules);
932 let mut client = authed_client(&http);
933
934 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
935 assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
936 }
937
938 #[test]
939 fn wav_url_is_none_until_the_render_is_ready() {
940 let mut rules = auth_rules();
941 rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
942 let http = MockHttp::new(rules);
943 let mut client = authed_client(&http);
944
945 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
946 assert_eq!(url, None);
947 }
948
949 #[test]
950 fn get_clips_by_ids_fetches_each_id_and_keeps_artefacts() {
951 let p1 = serde_json::json!({
955 "id": "p1", "title": "Infill Ancestor", "status": "complete",
956 "metadata": {"type": "gen", "task": "infill"}
957 })
958 .to_string();
959 let p2 = serde_json::json!({
960 "id": "p2", "title": "Uploaded Root", "status": "complete",
961 "metadata": {"type": "upload"}
962 })
963 .to_string();
964 let mut rules = auth_rules();
965 rules.push(Rule::new("/api/clip/p1", 200, p1));
966 rules.push(Rule::new("/api/clip/p2", 200, p2));
967 let http = MockHttp::new(rules);
968 let mut client = authed_client(&http);
969
970 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"])).unwrap();
971 assert_eq!(
972 clips.len(),
973 2,
974 "infill and upload ancestors must not be filtered"
975 );
976 assert_eq!(clips[0].id, "p1");
977 assert_eq!(clips[1].id, "p2");
978 }
979
980 #[test]
981 fn get_clips_by_ids_returns_a_trashed_clip() {
982 let trashed = serde_json::json!({
985 "id": "t1", "title": "Trashed Ancestor", "status": "complete",
986 "is_trashed": true, "metadata": {"type": "gen"}
987 })
988 .to_string();
989 let mut rules = auth_rules();
990 rules.push(Rule::new("/api/clip/t1", 200, trashed));
991 let http = MockHttp::new(rules);
992 let mut client = authed_client(&http);
993
994 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"])).unwrap();
995 assert_eq!(clips.len(), 1);
996 assert_eq!(clips[0].id, "t1");
997 assert!(clips[0].is_trashed);
998 }
999
1000 #[test]
1001 fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
1002 let only = serde_json::json!({
1003 "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
1004 })
1005 .to_string();
1006 let http = ScriptedHttp::new()
1007 .with_auth()
1008 .route("/api/clip/gone", Reply::status(404))
1009 .route("/api/clip/only", Reply::json(&only));
1010 let mut client = scripted_client(&http, RecordingClock::new());
1011
1012 let clips =
1013 pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"])).unwrap();
1014 assert_eq!(clips.len(), 1, "the 404 id is skipped");
1015 assert_eq!(clips[0].id, "only");
1016 assert_eq!(http.count("/api/clip/only"), 1);
1018 assert_eq!(http.count("/api/clip/gone"), 1);
1019 }
1020
1021 #[test]
1022 fn get_clip_parent_reads_the_parent_clip() {
1023 let parent = serde_json::json!({
1024 "id": "par", "title": "Ancestor", "status": "complete",
1025 "metadata": {"type": "gen"}
1026 })
1027 .to_string();
1028 let mut rules = auth_rules();
1029 rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
1030 let http = MockHttp::new(rules);
1031 let mut client = authed_client(&http);
1032
1033 let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
1034 assert_eq!(clip.unwrap().id, "par");
1035 }
1036
1037 #[test]
1038 fn get_clip_parent_is_none_for_a_root() {
1039 let mut rules = auth_rules();
1040 rules.push(Rule::new(
1041 "/api/clips/parent",
1042 404,
1043 r#"{"detail": "no parent"}"#.to_string(),
1044 ));
1045 let http = MockHttp::new(rules);
1046 let mut client = authed_client(&http);
1047
1048 let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
1049 assert!(clip.is_none());
1050 }
1051
1052 #[test]
1053 fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
1054 for status in [500u16, 503] {
1058 let mut rules = auth_rules();
1059 rules.push(Rule::new(
1060 "/api/clips/parent",
1061 status,
1062 r#"{"detail": "server error"}"#.to_string(),
1063 ));
1064 let http = MockHttp::new(rules);
1065 let mut client = authed_client(&http);
1066
1067 let result = pollster::block_on(client.get_clip_parent(&http, "child"));
1068 assert!(
1069 matches!(result, Err(Error::Api(_))),
1070 "status {status} must propagate as an error, not Ok(None)"
1071 );
1072 }
1073 }
1074
1075 #[test]
1076 fn get_playlists_maps_entries_and_skips_missing_ids() {
1077 let page1 = serde_json::json!({
1078 "playlists": [
1079 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
1080 {"id": "", "name": "No Id", "num_total_results": 3},
1081 {"name": "Also No Id"}
1082 ]
1083 })
1084 .to_string();
1085 let mut rules = auth_rules();
1086 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1088 rules.push(Rule::new(
1089 "/api/playlist/me?page=2",
1090 200,
1091 r#"{"playlists": []}"#.to_string(),
1092 ));
1093 let http = MockHttp::new(rules);
1094 let mut client = authed_client(&http);
1095
1096 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1097 assert_eq!(playlists.len(), 1, "entries without an id are dropped");
1098 assert_eq!(
1099 playlists[0],
1100 Playlist {
1101 id: "pl1".to_owned(),
1102 name: "Road Trip".to_owned(),
1103 num_clips: 12,
1104 }
1105 );
1106 }
1107
1108 #[test]
1109 fn get_playlists_defaults_a_missing_name_to_untitled() {
1110 let page1 = serde_json::json!({
1111 "playlists": [{"id": "pl9", "num_total_results": 1}]
1112 })
1113 .to_string();
1114 let mut rules = auth_rules();
1115 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1116 rules.push(Rule::new(
1117 "/api/playlist/me?page=2",
1118 200,
1119 r#"{"playlists": []}"#.to_string(),
1120 ));
1121 let http = MockHttp::new(rules);
1122 let mut client = authed_client(&http);
1123
1124 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1125 assert_eq!(playlists[0].name, "Untitled");
1126 }
1127
1128 #[test]
1129 fn get_playlist_clips_preserves_order_and_unwraps_clip() {
1130 let body = serde_json::json!({
1133 "playlist_clips": [
1134 {"clip": {
1135 "id": "second", "title": "Second", "status": "complete",
1136 "metadata": {"duration": 60.0, "type": "gen"}
1137 }},
1138 {"clip": {
1139 "id": "first", "title": "First", "status": "complete",
1140 "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
1141 }}
1142 ]
1143 })
1144 .to_string();
1145 let mut rules = auth_rules();
1146 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1147 let http = MockHttp::new(rules);
1148 let mut client = authed_client(&http);
1149
1150 let clips = pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1151 assert_eq!(clips.len(), 2, "an infill member is not filtered out");
1152 assert_eq!(clips[0].id, "second");
1153 assert_eq!(clips[1].id, "first");
1154 }
1155
1156 #[test]
1157 fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
1158 let mut rules = auth_rules();
1159 rules.push(Rule::new(
1160 "/api/playlist/empty/",
1161 200,
1162 r#"{"playlist_clips": []}"#.to_string(),
1163 ));
1164 let http = MockHttp::new(rules);
1165 let mut client = authed_client(&http);
1166
1167 let clips = pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
1168 assert!(clips.is_empty());
1169 }
1170}