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