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, BILLING_INFO_PATH, CLIP_PARENT_PATH, FEED_INITIAL_RATE, FEED_PAGE_SIZE,
12 FEED_V3_PATH, MAX_PAGES, 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::limiter::{AdaptiveLimiter, retry_after_delay};
18use crate::model::Clip;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Playlist {
28 pub id: String,
30 pub name: String,
32 pub num_clips: u64,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct BillingInfo {
39 pub total_credits_left: u64,
41}
42
43pub struct SunoClient<C> {
52 auth: ClerkAuth,
53 clock: C,
54 limiter: AdaptiveLimiter,
55}
56
57impl<C: Clock> SunoClient<C> {
58 pub fn new(auth: ClerkAuth, clock: C) -> Self {
60 Self {
61 auth,
62 clock,
63 limiter: AdaptiveLimiter::new(FEED_INITIAL_RATE),
64 }
65 }
66
67 pub fn auth(&self) -> &ClerkAuth {
69 &self.auth
70 }
71
72 #[cfg(test)]
76 pub(crate) fn limiter_rate(&self) -> f64 {
77 self.limiter.rate()
78 }
79
80 pub async fn list_clips(
96 &mut self,
97 http: &impl Http,
98 liked: bool,
99 limit: Option<usize>,
100 ) -> Result<(Vec<Clip>, bool)> {
101 let mut clips = Vec::new();
102 let mut cursor: Option<String> = None;
103 let mut complete = false;
104 for _ in 0..MAX_PAGES {
105 let body = feed_v3_body(liked, cursor.as_deref());
106 let response = self
107 .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
108 .await?;
109 let (page_clips, has_more, next_cursor) = parse_feed_v3(&response)?;
110 clips.extend(page_clips);
111 match has_more {
112 Some(false) => {
113 complete = true;
114 break;
115 }
116 Some(true) => match next_cursor {
117 Some(next) => cursor = Some(next),
118 None => break,
119 },
120 None => break,
121 }
122 if limit.is_some_and(|n| clips.len() >= n) {
123 break;
124 }
125 }
126 if let Some(n) = limit {
127 clips.truncate(n);
128 }
129 Ok((clips, complete))
130 }
131
132 pub async fn get_clip(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
138 if let Some(clip) = self.try_get_clip(http, id).await? {
139 return Ok(clip);
140 }
141 self.find_in_feed(http, id).await
142 }
143
144 pub async fn request_wav(&mut self, http: &impl Http, id: &str) -> Result<()> {
146 let path = format!("/api/gen/{id}/convert_wav/");
147 self.api_request(http, Method::Post, &path, Vec::new())
148 .await?;
149 Ok(())
150 }
151
152 pub async fn wav_url(&mut self, http: &impl Http, id: &str) -> Result<Option<String>> {
154 let path = format!("/api/gen/{id}/wav_file/");
155 let body = self.api_get(http, &path).await?;
156 let data: Value = serde_json::from_slice(&body)
157 .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
158 Ok(data
159 .get("wav_file_url")
160 .and_then(Value::as_str)
161 .filter(|url| !url.is_empty())
162 .map(str::to_string))
163 }
164
165 pub async fn get_clips_by_ids(&mut self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
178 let mut clips = Vec::new();
179 let mut seen: BTreeSet<&str> = BTreeSet::new();
180 for id in ids {
181 if id.is_empty() || !seen.insert(id) {
182 continue;
183 }
184 let path = format!("/api/clip/{id}");
185 match self.api_get_retrying(http, &path).await {
186 Ok(body) => {
187 if let Some(clip) = parse_clip(&body) {
188 clips.push(clip);
189 }
190 }
191 Err(Error::NotFound(_)) => continue,
192 Err(err) => return Err(err),
193 }
194 }
195 Ok(clips)
196 }
197
198 pub async fn get_clip_parent(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
206 let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
207 match self.api_get_retrying(http, &path).await {
208 Ok(body) => Ok(parse_clip(&body)),
209 Err(Error::NotFound(_)) => Ok(None),
210 Err(err) => Err(err),
211 }
212 }
213
214 pub async fn get_playlists(&mut self, http: &impl Http) -> Result<Vec<Playlist>> {
225 let mut playlists = Vec::new();
226 for page in 1..=MAX_PAGES {
227 let path =
228 format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
229 let body = self.api_get_retrying(http, &path).await?;
230 let page_playlists = parse_playlists(&body)?;
231 if page_playlists.is_empty() {
232 break;
233 }
234 playlists.extend(page_playlists);
235 }
236 Ok(playlists)
237 }
238
239 pub async fn get_playlist_clips(
255 &mut self,
256 http: &impl Http,
257 id: &str,
258 ) -> Result<(Vec<Clip>, bool)> {
259 let path = format!("{PLAYLIST_PATH}{id}/");
260 let body = self.api_get_retrying(http, &path).await?;
261 parse_playlist_clips(&body)
262 }
263
264 pub async fn get_billing_info(&mut self, http: &impl Http) -> Result<BillingInfo> {
266 let body = self.api_get_retrying(http, BILLING_INFO_PATH).await?;
267 parse_billing_info(&body)
268 }
269
270 async fn try_get_clip(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
273 let path = format!("/api/clip/{id}");
274 match self.api_get_retrying(http, &path).await {
275 Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
276 Err(Error::NotFound(_)) => Ok(None),
277 Err(err) => Err(err),
278 }
279 }
280
281 async fn find_in_feed(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
283 let (clips, _complete) = self.list_clips(http, false, None).await?;
284 clips
285 .into_iter()
286 .find(|clip| clip.id == id)
287 .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
288 }
289
290 async fn api_get(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
292 self.api_request(http, Method::Get, path, Vec::new()).await
293 }
294
295 async fn api_get_retrying(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
297 self.api_send_retrying(http, Method::Get, path, Vec::new())
298 .await
299 }
300
301 async fn api_send_retrying(
320 &mut self,
321 http: &impl Http,
322 method: Method,
323 path: &str,
324 body: Vec<u8>,
325 ) -> Result<Vec<u8>> {
326 let pace = self.limiter.pace();
327 if !pace.is_zero() {
328 self.clock.sleep(pace).await;
329 }
330 let mut retries = 0;
331 loop {
332 match self.api_request(http, method, path, body.clone()).await {
333 Ok(response) => return Ok(response),
334 Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
335 self.clock.sleep(retry_after_delay(retry_after)).await;
336 retries += 1;
337 }
338 Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
339 self.clock.sleep(backoff_delay(retries, None)).await;
340 retries += 1;
341 }
342 Err(err) => return Err(err),
343 }
344 }
345 }
346
347 async fn api_request(
352 &mut self,
353 http: &impl Http,
354 method: Method,
355 path: &str,
356 body: Vec<u8>,
357 ) -> Result<Vec<u8>> {
358 let url = format!("{SUNO_API_BASE_URL}{path}");
359 let mut auth_refreshed = false;
360 loop {
361 let jwt = self.auth.ensure_jwt(http).await?;
362 let mut request = match method {
363 Method::Get => HttpRequest::get(url.clone()),
364 Method::Post => HttpRequest::post(url.clone(), body.clone()),
365 };
366 request
367 .headers
368 .push(("Authorization".to_string(), format!("Bearer {jwt}")));
369 let response = http
370 .send(request)
371 .await
372 .map_err(|err| Error::Connection(err.to_string()))?;
373 match response.status {
374 200..=299 => {
375 self.limiter.on_success();
376 return Ok(response.body);
377 }
378 401 | 403 if !auth_refreshed => {
379 self.auth.invalidate_jwt();
380 auth_refreshed = true;
381 }
382 401 | 403 => {
383 return Err(Error::Auth(format!(
384 "Suno API auth failed with status {}",
385 response.status
386 )));
387 }
388 429 => {
389 self.limiter.on_rate_limit();
390 return Err(Error::RateLimited {
391 retry_after: retry_after(&response),
392 });
393 }
394 404 => {
395 return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
396 }
397 status => {
398 let preview: String = String::from_utf8_lossy(&response.body)
399 .chars()
400 .take(200)
401 .collect();
402 return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
403 }
404 }
405 }
406 }
407}
408
409fn unwrap_clip(value: &Value) -> &Value {
412 value
413 .get("clip")
414 .filter(|clip| clip.is_object())
415 .unwrap_or(value)
416}
417
418fn parse_clip(body: &[u8]) -> Option<Clip> {
421 let data: Value = serde_json::from_slice(body).ok()?;
422 let raw = unwrap_clip(&data);
423 let has_id = raw
424 .get("id")
425 .and_then(Value::as_str)
426 .is_some_and(|id| !id.is_empty());
427 has_id.then(|| Clip::from_json(raw))
428}
429
430fn parse_billing_info(body: &[u8]) -> Result<BillingInfo> {
432 let data: Value = serde_json::from_slice(body)
433 .map_err(|err| Error::Api(format!("invalid billing JSON: {err}")))?;
434 let total_credits_left = data
435 .get("total_credits_left")
436 .and_then(json_u64)
437 .ok_or_else(|| Error::Api("invalid billing JSON: missing total_credits_left".into()))?;
438 Ok(BillingInfo { total_credits_left })
439}
440
441fn json_u64(value: &Value) -> Option<u64> {
444 match value {
445 Value::Number(number) => number.as_u64(),
446 Value::String(text) => text.parse().ok(),
447 _ => None,
448 }
449}
450
451fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
458 let mut filters = serde_json::Map::new();
459 filters.insert("trashed".to_string(), Value::String("False".to_string()));
460 if liked {
461 filters.insert("liked".to_string(), Value::String("True".to_string()));
462 }
463 let mut body = serde_json::Map::new();
464 body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
465 body.insert("filters".to_string(), Value::Object(filters));
466 if let Some(cursor) = cursor {
467 body.insert("cursor".to_string(), Value::String(cursor.to_string()));
468 }
469 serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
470}
471
472fn parse_feed_v3(body: &[u8]) -> Result<(Vec<Clip>, Option<bool>, Option<String>)> {
479 let data: Value = serde_json::from_slice(body)
480 .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
481 let Some(object) = data.as_object() else {
482 return Ok((Vec::new(), None, None));
483 };
484 let clips = object
485 .get("clips")
486 .and_then(Value::as_array)
487 .map(|raw| {
488 raw.iter()
489 .map(Clip::from_json)
490 .filter(is_downloadable)
491 .collect()
492 })
493 .unwrap_or_default();
494 let has_more = object.get("has_more").and_then(Value::as_bool);
495 let next_cursor = object
496 .get("next_cursor")
497 .and_then(Value::as_str)
498 .filter(|cursor| !cursor.is_empty())
499 .map(str::to_string);
500 Ok((clips, has_more, next_cursor))
501}
502
503fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
505 let data: Value = serde_json::from_slice(body)
506 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
507 Ok(data
508 .get("playlists")
509 .and_then(Value::as_array)
510 .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
511 .unwrap_or_default())
512}
513
514fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
519 let id = raw
520 .get("id")
521 .and_then(Value::as_str)
522 .filter(|id| !id.is_empty())?
523 .to_string();
524 let name = match raw.get("name") {
525 Some(Value::String(name)) if !name.is_empty() => name.clone(),
526 _ => "Untitled".to_string(),
527 };
528 let num_clips = raw
529 .get("num_total_results")
530 .and_then(Value::as_u64)
531 .unwrap_or(0);
532 Some(Playlist {
533 id,
534 name,
535 num_clips,
536 })
537}
538
539fn parse_playlist_clips(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
556 let data: Value = serde_json::from_slice(body)
557 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
558 let raw = data.get("playlist_clips").and_then(Value::as_array);
559 let raw_len = raw.map(|a| a.len()).unwrap_or(0);
560 let clips: Vec<Clip> = raw
561 .map(|raw| {
562 raw.iter()
563 .map(|entry| Clip::from_json(unwrap_clip(entry)))
564 .filter(|clip| !clip.id.is_empty())
565 .collect()
566 })
567 .unwrap_or_default();
568 let complete = data
574 .get("num_total_results")
575 .and_then(Value::as_u64)
576 .is_some_and(|total| raw_len as u64 == total);
577 Ok((clips, complete))
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583 use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
584 use std::time::Duration;
585
586 fn feed_body() -> String {
587 serde_json::json!({
588 "has_more": false,
589 "clips": [
590 {
591 "id": "a", "title": "Song A", "status": "complete",
592 "audio_url": "https://cdn1.suno.ai/a.mp3",
593 "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
594 },
595 {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
596 {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
597 {
598 "id": "d", "title": "Context", "status": "complete",
599 "metadata": {"type": "rendered_context_window"}
600 }
601 ]
602 })
603 .to_string()
604 }
605
606 #[test]
607 fn parse_feed_v3_filters_and_reads_pagination() {
608 let (clips, has_more, next_cursor) = parse_feed_v3(feed_body().as_bytes()).unwrap();
609 assert_eq!(has_more, Some(false));
610 assert_eq!(next_cursor, None);
611 assert_eq!(clips.len(), 1);
612 assert_eq!(clips[0].id, "a");
613 assert_eq!(clips[0].tags, "rock");
614 assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
615 }
616
617 #[test]
618 fn feed_v3_body_carries_filters_and_optional_cursor() {
619 let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
620 assert_eq!(first["filters"]["trashed"], "False");
621 assert!(first.get("cursor").is_none());
622 assert!(first["filters"].get("liked").is_none());
623
624 let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
625 assert_eq!(liked["filters"]["liked"], "True");
626 assert_eq!(liked["cursor"], "cur42");
627 }
628
629 #[test]
630 fn audiopipe_url_is_rewritten_to_cdn() {
631 let raw =
632 serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
633 assert_eq!(
634 Clip::from_json(&raw).audio_url,
635 "https://cdn1.suno.ai/x.mp3"
636 );
637 }
638
639 #[test]
640 fn list_clips_authenticates_then_reads_the_feed() {
641 let client_body = serde_json::json!({
642 "response": {
643 "last_active_session_id": "s",
644 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
645 }
646 })
647 .to_string();
648 let http = MockHttp::new(vec![
649 Rule::new(
650 "/v1/client/sessions/",
651 200,
652 r#"{"jwt": "a.b.c"}"#.to_string(),
653 ),
654 Rule::new("/v1/client", 200, client_body),
655 Rule::new("/api/feed/v3", 200, feed_body()),
656 ]);
657
658 let mut auth = ClerkAuth::new("eyJtoken");
659 pollster::block_on(auth.authenticate(&http)).unwrap();
660 let mut client = SunoClient::new(auth, RecordingClock::new());
661 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
662 assert_eq!(clips.len(), 1);
663 assert_eq!(clips[0].id, "a");
664 assert!(complete);
665 }
666
667 #[test]
668 fn list_clips_reports_incomplete_when_paging_is_capped() {
669 let mut rules = auth_rules();
670 rules.push(Rule::new(
671 "/api/feed/v3",
672 200,
673 serde_json::json!({
674 "has_more": true,
675 "next_cursor": "cur1",
676 "clips": [{
677 "id": "a", "title": "Song A", "status": "complete",
678 "audio_url": "https://cdn1.suno.ai/a.mp3",
679 "metadata": {"type": "gen"}
680 }]
681 })
682 .to_string(),
683 ));
684 let http = MockHttp::new(rules);
685 let mut client = authed_client(&http);
686
687 let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
688 assert!(!complete);
689 }
690
691 fn auth_rules() -> Vec<Rule> {
692 let client_body = serde_json::json!({
693 "response": {
694 "last_active_session_id": "s",
695 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
696 }
697 })
698 .to_string();
699 vec![
700 Rule::new(
701 "/v1/client/sessions/",
702 200,
703 r#"{"jwt": "a.b.c"}"#.to_string(),
704 ),
705 Rule::new("/v1/client", 200, client_body),
706 ]
707 }
708
709 fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
710 let mut auth = ClerkAuth::new("eyJtoken");
711 pollster::block_on(auth.authenticate(http)).unwrap();
712 SunoClient::new(auth, RecordingClock::new())
713 }
714
715 #[test]
716 fn get_billing_info_reads_remaining_credits() {
717 let mut rules = auth_rules();
718 rules.push(Rule::new(
719 BILLING_INFO_PATH,
720 200,
721 r#"{"total_credits_left":500,"monthly_limit":1000,"monthly_usage":500}"#.to_string(),
722 ));
723 let http = MockHttp::new(rules);
724 let mut client = authed_client(&http);
725
726 let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
727 assert_eq!(billing.total_credits_left, 500);
728 }
729
730 #[test]
731 fn get_billing_info_rejects_missing_balance() {
732 let mut rules = auth_rules();
733 rules.push(Rule::new(
734 BILLING_INFO_PATH,
735 200,
736 r#"{"monthly_usage":12}"#.to_string(),
737 ));
738 let http = MockHttp::new(rules);
739 let mut client = authed_client(&http);
740
741 let err = pollster::block_on(client.get_billing_info(&http)).unwrap_err();
742 assert!(err.to_string().contains("total_credits_left"));
743 }
744
745 fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
746 let mut auth = ClerkAuth::new("eyJtoken");
747 pollster::block_on(auth.authenticate(http)).unwrap();
748 SunoClient::new(auth, clock)
749 }
750
751 fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
752 let mut page = serde_json::json!({
753 "has_more": next_cursor.is_some(),
754 "clips": [{
755 "id": id, "title": "Song", "status": "complete",
756 "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
757 "metadata": {"type": "gen"}
758 }]
759 });
760 if let Some(cursor) = next_cursor {
761 page["next_cursor"] = serde_json::json!(cursor);
762 }
763 page.to_string()
764 }
765
766 #[test]
767 fn list_clips_retries_a_rate_limited_page() {
768 let http = ScriptedHttp::new().with_auth().route_seq(
769 "/api/feed/v3",
770 vec![Reply::status(429), Reply::json(&feed_body())],
771 );
772 let clock = RecordingClock::new();
773 let mut client = scripted_client(&http, clock.clone());
774
775 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
776 assert_eq!(clips.len(), 1);
777 assert!(complete);
778 assert_eq!(http.count("/api/feed/v3"), 2);
780 assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
781 }
782
783 #[test]
784 fn list_clips_honours_retry_after_on_a_throttled_page() {
785 let http = ScriptedHttp::new().with_auth().route_seq(
786 "/api/feed/v3",
787 vec![
788 Reply::status(429).with_retry_after(7),
789 Reply::json(&feed_body()),
790 ],
791 );
792 let clock = RecordingClock::new();
793 let mut client = scripted_client(&http, clock.clone());
794
795 let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
796 assert_eq!(clips.len(), 1);
797 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
799 }
800
801 #[test]
802 fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
803 let http = ScriptedHttp::new().with_auth().route_seq(
805 "/api/feed/v3",
806 vec![
807 Reply::json(&one_clip_page("a", Some("cur1"))),
808 Reply::status(429),
809 Reply::json(&one_clip_page("b", None)),
810 ],
811 );
812 let clock = RecordingClock::new();
813 let mut client = scripted_client(&http, clock.clone());
814
815 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
816 assert!(complete);
817 assert_eq!(clips.len(), 2);
818 let bodies = http.bodies();
819 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
820 assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
821 let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
824 let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
825 assert_eq!(retried["cursor"], "cur1");
826 assert_eq!(after_retry["cursor"], "cur1");
827 }
828
829 #[test]
830 fn list_clips_threads_the_cursor_across_pages() {
831 let http = ScriptedHttp::new().with_auth().route_seq(
832 "/api/feed/v3",
833 vec![
834 Reply::json(&one_clip_page("a", Some("cur1"))),
835 Reply::json(&one_clip_page("b", None)),
836 ],
837 );
838 let clock = RecordingClock::new();
839 let mut client = scripted_client(&http, clock.clone());
840
841 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
842 assert!(complete);
843 assert_eq!(clips.len(), 2);
844 let bodies = http.bodies();
845 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
846 assert_eq!(feed_bodies.len(), 2);
847 let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
848 let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
849 assert!(page1.get("cursor").is_none());
851 assert_eq!(page2["cursor"], "cur1");
852 }
853
854 #[test]
855 fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
856 let page = serde_json::json!({
859 "has_more": true,
860 "clips": [{
861 "id": "a", "title": "Song", "status": "complete",
862 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
863 }]
864 })
865 .to_string();
866 let http = ScriptedHttp::new()
867 .with_auth()
868 .route("/api/feed/v3", Reply::json(&page));
869 let clock = RecordingClock::new();
870 let mut client = scripted_client(&http, clock.clone());
871
872 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
873 assert!(!complete);
874 assert_eq!(clips.len(), 1);
875 assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
876 }
877
878 #[test]
879 fn list_clips_is_incomplete_when_has_more_is_missing() {
880 let page = serde_json::json!({
882 "clips": [{
883 "id": "a", "title": "Song", "status": "complete",
884 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
885 }]
886 })
887 .to_string();
888 let http = ScriptedHttp::new()
889 .with_auth()
890 .route("/api/feed/v3", Reply::json(&page));
891 let clock = RecordingClock::new();
892 let mut client = scripted_client(&http, clock.clone());
893
894 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
895 assert!(!complete);
896 assert_eq!(clips.len(), 1);
897 assert_eq!(http.count("/api/feed/v3"), 1);
898 }
899
900 #[test]
901 fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
902 let http = ScriptedHttp::new().with_auth().route_seq(
903 "/api/feed/v3",
904 vec![
905 Reply::json(&one_clip_page("a", Some("cur1"))),
906 Reply::status(500),
907 ],
908 );
909 let clock = RecordingClock::new();
910 let mut client = scripted_client(&http, clock.clone());
911
912 let result = pollster::block_on(client.list_clips(&http, false, None));
913 assert!(matches!(result, Err(Error::Api(_))));
914 }
915
916 #[test]
917 fn list_clips_is_complete_on_an_empty_drained_feed() {
918 let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
921 let http = ScriptedHttp::new()
922 .with_auth()
923 .route("/api/feed/v3", Reply::json(&page));
924 let clock = RecordingClock::new();
925 let mut client = scripted_client(&http, clock.clone());
926
927 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
928 assert!(complete);
929 assert!(clips.is_empty());
930 }
931
932 #[test]
933 fn list_clips_liked_scope_sends_the_liked_filter() {
934 let http = ScriptedHttp::new()
935 .with_auth()
936 .route("/api/feed/v3", Reply::json(&feed_body()));
937 let clock = RecordingClock::new();
938 let mut client = scripted_client(&http, clock.clone());
939
940 let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
941 let bodies = http.bodies();
942 let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
943 let value: Value = serde_json::from_str(feed_body).unwrap();
944 assert_eq!(value["filters"]["liked"], "True");
945 assert_eq!(value["filters"]["trashed"], "False");
946 }
947
948 #[test]
949 fn list_clips_does_not_pace_an_unthrottled_walk() {
950 let http = ScriptedHttp::new().with_auth().route_seq(
951 "/api/feed/v3",
952 vec![
953 Reply::json(&one_clip_page("a", Some("cur1"))),
954 Reply::json(&one_clip_page("e", None)),
955 ],
956 );
957 let clock = RecordingClock::new();
958 let mut client = scripted_client(&http, clock.clone());
959
960 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
961 assert!(complete);
962 assert_eq!(clips.len(), 2);
963 assert_eq!(http.count("/api/feed/v3"), 2);
964 assert!(clock.sleeps().is_empty());
966 }
967
968 #[test]
969 fn list_clips_slows_its_pace_after_a_throttled_page() {
970 let http = ScriptedHttp::new().with_auth().route_seq(
971 "/api/feed/v3",
972 vec![
973 Reply::status(429),
974 Reply::json(&one_clip_page("a", Some("cur1"))),
975 Reply::json(&one_clip_page("e", None)),
976 ],
977 );
978 let clock = RecordingClock::new();
979 let mut client = scripted_client(&http, clock.clone());
980
981 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
982 assert!(complete);
983 assert_eq!(clips.len(), 2);
984 assert_eq!(
987 clock.sleeps(),
988 vec![Duration::from_secs(5), Duration::from_secs(1)]
989 );
990 }
991
992 #[test]
993 fn list_clips_gives_up_after_max_retries() {
994 let http = ScriptedHttp::new()
995 .with_auth()
996 .route("/api/feed/v3", Reply::status(429));
997 let clock = RecordingClock::new();
998 let mut client = scripted_client(&http, clock.clone());
999
1000 let result = pollster::block_on(client.list_clips(&http, false, None));
1001 assert!(matches!(result, Err(Error::RateLimited { .. })));
1002 let budget = crate::consts::API_MAX_RETRIES as usize;
1003 assert_eq!(clock.sleeps().len(), budget);
1004 assert_eq!(http.count("/api/feed/v3"), budget + 1);
1005 }
1006
1007 #[test]
1008 fn parse_clip_accepts_bare_and_wrapped_shapes() {
1009 let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
1010 assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
1011
1012 let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
1013 assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
1014
1015 let missing = serde_json::json!({"detail": "not found"}).to_string();
1016 assert!(parse_clip(missing.as_bytes()).is_none());
1017 }
1018
1019 #[test]
1020 fn get_clip_uses_the_dedicated_endpoint() {
1021 let clip_body = serde_json::json!({
1022 "id": "z", "title": "Zed", "status": "complete",
1023 "audio_url": "https://cdn1.suno.ai/z.mp3",
1024 "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
1025 })
1026 .to_string();
1027 let mut rules = auth_rules();
1028 rules.push(Rule::new("/api/clip/", 200, clip_body));
1029 let http = MockHttp::new(rules);
1030 let mut client = authed_client(&http);
1031
1032 let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
1033 assert_eq!(clip.id, "z");
1034 assert_eq!(clip.title, "Zed");
1035 assert_eq!(clip.tags, "jazz");
1036 }
1037
1038 #[test]
1039 fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
1040 let mut rules = auth_rules();
1041 rules.push(Rule::new(
1042 "/api/clip/",
1043 404,
1044 r#"{"detail": "not found"}"#.to_string(),
1045 ));
1046 rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
1047 let http = MockHttp::new(rules);
1048 let mut client = authed_client(&http);
1049
1050 let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
1051 assert_eq!(clip.id, "a");
1052 assert_eq!(clip.tags, "rock");
1053 }
1054
1055 #[test]
1056 fn request_wav_accepts_a_2xx_status() {
1057 let mut rules = auth_rules();
1058 rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
1059 let http = MockHttp::new(rules);
1060 let mut client = authed_client(&http);
1061
1062 assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
1063 }
1064
1065 #[test]
1066 fn wav_url_reads_the_ready_url() {
1067 let mut rules = auth_rules();
1068 rules.push(Rule::new(
1069 "/wav_file/",
1070 200,
1071 r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
1072 ));
1073 let http = MockHttp::new(rules);
1074 let mut client = authed_client(&http);
1075
1076 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1077 assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
1078 }
1079
1080 #[test]
1081 fn wav_url_is_none_until_the_render_is_ready() {
1082 let mut rules = auth_rules();
1083 rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
1084 let http = MockHttp::new(rules);
1085 let mut client = authed_client(&http);
1086
1087 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1088 assert_eq!(url, None);
1089 }
1090
1091 #[test]
1092 fn get_clips_by_ids_fetches_each_id_and_keeps_artefacts() {
1093 let p1 = serde_json::json!({
1097 "id": "p1", "title": "Infill Ancestor", "status": "complete",
1098 "metadata": {"type": "gen", "task": "infill"}
1099 })
1100 .to_string();
1101 let p2 = serde_json::json!({
1102 "id": "p2", "title": "Uploaded Root", "status": "complete",
1103 "metadata": {"type": "upload"}
1104 })
1105 .to_string();
1106 let mut rules = auth_rules();
1107 rules.push(Rule::new("/api/clip/p1", 200, p1));
1108 rules.push(Rule::new("/api/clip/p2", 200, p2));
1109 let http = MockHttp::new(rules);
1110 let mut client = authed_client(&http);
1111
1112 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"])).unwrap();
1113 assert_eq!(
1114 clips.len(),
1115 2,
1116 "infill and upload ancestors must not be filtered"
1117 );
1118 assert_eq!(clips[0].id, "p1");
1119 assert_eq!(clips[1].id, "p2");
1120 }
1121
1122 #[test]
1123 fn get_clips_by_ids_returns_a_trashed_clip() {
1124 let trashed = serde_json::json!({
1127 "id": "t1", "title": "Trashed Ancestor", "status": "complete",
1128 "is_trashed": true, "metadata": {"type": "gen"}
1129 })
1130 .to_string();
1131 let mut rules = auth_rules();
1132 rules.push(Rule::new("/api/clip/t1", 200, trashed));
1133 let http = MockHttp::new(rules);
1134 let mut client = authed_client(&http);
1135
1136 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"])).unwrap();
1137 assert_eq!(clips.len(), 1);
1138 assert_eq!(clips[0].id, "t1");
1139 assert!(clips[0].is_trashed);
1140 }
1141
1142 #[test]
1143 fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
1144 let only = serde_json::json!({
1145 "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
1146 })
1147 .to_string();
1148 let http = ScriptedHttp::new()
1149 .with_auth()
1150 .route("/api/clip/gone", Reply::status(404))
1151 .route("/api/clip/only", Reply::json(&only));
1152 let mut client = scripted_client(&http, RecordingClock::new());
1153
1154 let clips =
1155 pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"])).unwrap();
1156 assert_eq!(clips.len(), 1, "the 404 id is skipped");
1157 assert_eq!(clips[0].id, "only");
1158 assert_eq!(http.count("/api/clip/only"), 1);
1160 assert_eq!(http.count("/api/clip/gone"), 1);
1161 }
1162
1163 #[test]
1164 fn get_clip_parent_reads_the_parent_clip() {
1165 let parent = serde_json::json!({
1166 "id": "par", "title": "Ancestor", "status": "complete",
1167 "metadata": {"type": "gen"}
1168 })
1169 .to_string();
1170 let mut rules = auth_rules();
1171 rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
1172 let http = MockHttp::new(rules);
1173 let mut client = authed_client(&http);
1174
1175 let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
1176 assert_eq!(clip.unwrap().id, "par");
1177 }
1178
1179 #[test]
1180 fn get_clip_parent_is_none_for_a_root() {
1181 let mut rules = auth_rules();
1182 rules.push(Rule::new(
1183 "/api/clips/parent",
1184 404,
1185 r#"{"detail": "no parent"}"#.to_string(),
1186 ));
1187 let http = MockHttp::new(rules);
1188 let mut client = authed_client(&http);
1189
1190 let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
1191 assert!(clip.is_none());
1192 }
1193
1194 #[test]
1195 fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
1196 for status in [500u16, 503] {
1200 let mut rules = auth_rules();
1201 rules.push(Rule::new(
1202 "/api/clips/parent",
1203 status,
1204 r#"{"detail": "server error"}"#.to_string(),
1205 ));
1206 let http = MockHttp::new(rules);
1207 let mut client = authed_client(&http);
1208
1209 let result = pollster::block_on(client.get_clip_parent(&http, "child"));
1210 assert!(
1211 matches!(result, Err(Error::Api(_))),
1212 "status {status} must propagate as an error, not Ok(None)"
1213 );
1214 }
1215 }
1216
1217 #[test]
1218 fn get_playlists_maps_entries_and_skips_missing_ids() {
1219 let page1 = serde_json::json!({
1220 "playlists": [
1221 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
1222 {"id": "", "name": "No Id", "num_total_results": 3},
1223 {"name": "Also No Id"}
1224 ]
1225 })
1226 .to_string();
1227 let mut rules = auth_rules();
1228 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1230 rules.push(Rule::new(
1231 "/api/playlist/me?page=2",
1232 200,
1233 r#"{"playlists": []}"#.to_string(),
1234 ));
1235 let http = MockHttp::new(rules);
1236 let mut client = authed_client(&http);
1237
1238 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1239 assert_eq!(playlists.len(), 1, "entries without an id are dropped");
1240 assert_eq!(
1241 playlists[0],
1242 Playlist {
1243 id: "pl1".to_owned(),
1244 name: "Road Trip".to_owned(),
1245 num_clips: 12,
1246 }
1247 );
1248 }
1249
1250 #[test]
1251 fn get_playlists_defaults_a_missing_name_to_untitled() {
1252 let page1 = serde_json::json!({
1253 "playlists": [{"id": "pl9", "num_total_results": 1}]
1254 })
1255 .to_string();
1256 let mut rules = auth_rules();
1257 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1258 rules.push(Rule::new(
1259 "/api/playlist/me?page=2",
1260 200,
1261 r#"{"playlists": []}"#.to_string(),
1262 ));
1263 let http = MockHttp::new(rules);
1264 let mut client = authed_client(&http);
1265
1266 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1267 assert_eq!(playlists[0].name, "Untitled");
1268 }
1269
1270 #[test]
1271 fn get_playlist_clips_preserves_order_and_unwraps_clip() {
1272 let body = serde_json::json!({
1275 "num_total_results": 2,
1276 "playlist_clips": [
1277 {"clip": {
1278 "id": "second", "title": "Second", "status": "complete",
1279 "metadata": {"duration": 60.0, "type": "gen"}
1280 }},
1281 {"clip": {
1282 "id": "first", "title": "First", "status": "complete",
1283 "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
1284 }}
1285 ]
1286 })
1287 .to_string();
1288 let mut rules = auth_rules();
1289 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1290 let http = MockHttp::new(rules);
1291 let mut client = authed_client(&http);
1292
1293 let (clips, complete) =
1294 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1295 assert_eq!(clips.len(), 2, "an infill member is not filtered out");
1296 assert_eq!(clips[0].id, "second");
1297 assert_eq!(clips[1].id, "first");
1298 assert!(
1299 complete,
1300 "returned == num_total_results is fully enumerated"
1301 );
1302 }
1303
1304 #[test]
1305 fn get_playlist_clips_short_page_is_not_complete() {
1306 let body = serde_json::json!({
1308 "num_total_results": 5,
1309 "playlist_clips": [
1310 {"clip": {
1311 "id": "only", "title": "Only", "status": "complete",
1312 "metadata": {"duration": 60.0, "type": "gen"}
1313 }}
1314 ]
1315 })
1316 .to_string();
1317 let mut rules = auth_rules();
1318 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1319 let http = MockHttp::new(rules);
1320 let mut client = authed_client(&http);
1321
1322 let (clips, complete) =
1323 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1324 assert_eq!(clips.len(), 1);
1325 assert!(!complete, "a short page is not fully enumerated");
1326 }
1327
1328 #[test]
1329 fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
1330 let mut rules = auth_rules();
1331 rules.push(Rule::new(
1332 "/api/playlist/empty/",
1333 200,
1334 r#"{"num_total_results": 0, "playlist_clips": []}"#.to_string(),
1335 ));
1336 let http = MockHttp::new(rules);
1337 let mut client = authed_client(&http);
1338
1339 let (clips, complete) =
1340 pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
1341 assert!(clips.is_empty());
1342 assert!(
1343 complete,
1344 "an empty playlist reporting zero total is complete"
1345 );
1346 }
1347
1348 #[test]
1349 fn get_playlist_clips_missing_total_is_not_complete() {
1350 let mut rules = auth_rules();
1354 rules.push(Rule::new(
1355 "/api/playlist/pl1/",
1356 200,
1357 r#"{"playlist_clips": []}"#.to_string(),
1358 ));
1359 let http = MockHttp::new(rules);
1360 let mut client = authed_client(&http);
1361
1362 let (clips, complete) =
1363 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1364 assert!(clips.is_empty());
1365 assert!(!complete, "a missing total is never fully enumerated");
1366 }
1367}