1use std::collections::BTreeSet;
4use std::sync::Mutex;
5use std::time::Instant;
6
7use futures_util::stream::{self, StreamExt};
8use serde_json::Value;
9
10use crate::auth::ClerkAuth;
11use crate::backoff::{backoff_delay, retry_after};
12use crate::clock::Clock;
13use crate::consts::{
14 API_MAX_RETRIES, BILLING_INFO_PATH, CLIP_PARENT_PATH, FEED_INITIAL_RATE, FEED_PAGE_SIZE,
15 FEED_V3_PATH, MAX_PAGES, PLAYLIST_ME_PATH, PLAYLIST_PATH, SUNO_API_BASE_URL,
16};
17use crate::error::{Error, Result};
18use crate::http::{Http, HttpRequest, Method};
19use crate::is_downloadable;
20use crate::limiter::{AdaptiveLimiter, retry_after_delay};
21use crate::lyrics::AlignedLyrics;
22use crate::model::Clip;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Playlist {
32 pub id: String,
34 pub name: String,
36 pub num_clips: u64,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct BillingInfo {
43 pub total_credits_left: u64,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Stem {
58 pub id: String,
61 pub label: String,
65 pub url: String,
67}
68
69pub struct SunoClient<C> {
78 auth: ClerkAuth,
79 clock: C,
80 limiter: Mutex<AdaptiveLimiter>,
81}
82
83impl<C: Clock> SunoClient<C> {
84 pub fn new(auth: ClerkAuth, clock: C) -> Self {
86 Self {
87 auth,
88 clock,
89 limiter: Mutex::new(AdaptiveLimiter::new(FEED_INITIAL_RATE)),
90 }
91 }
92
93 pub fn auth(&self) -> &ClerkAuth {
95 &self.auth
96 }
97
98 #[cfg(test)]
102 pub(crate) fn limiter_rate(&self) -> f64 {
103 self.limiter.lock().unwrap().rate()
104 }
105
106 pub async fn list_clips(
122 &self,
123 http: &impl Http,
124 liked: bool,
125 limit: Option<usize>,
126 ) -> Result<(Vec<Clip>, bool)> {
127 let mut clips = Vec::new();
128 let mut cursor: Option<String> = None;
129 let mut complete = false;
130 for _ in 0..MAX_PAGES {
131 let body = feed_v3_body(liked, cursor.as_deref());
132 let response = self
133 .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
134 .await?;
135 let (page_clips, has_more, next_cursor) = parse_feed_v3(&response)?;
136 clips.extend(page_clips);
137 match has_more {
138 Some(false) => {
139 complete = true;
140 break;
141 }
142 Some(true) => match next_cursor {
143 Some(next) => cursor = Some(next),
144 None => break,
145 },
146 None => break,
147 }
148 if limit.is_some_and(|n| clips.len() >= n) {
149 break;
150 }
151 }
152 if let Some(n) = limit {
153 clips.truncate(n);
154 }
155 Ok((clips, complete))
156 }
157
158 pub async fn get_clip(&self, http: &impl Http, id: &str) -> Result<Clip> {
164 if let Some(clip) = self.try_get_clip(http, id).await? {
165 return Ok(clip);
166 }
167 self.find_in_feed(http, id).await
168 }
169
170 pub async fn request_wav(&self, http: &impl Http, id: &str) -> Result<()> {
172 let path = format!("/api/gen/{id}/convert_wav/");
173 self.api_request(http, Method::Post, &path, Vec::new())
174 .await?;
175 Ok(())
176 }
177
178 pub async fn wav_url(&self, http: &impl Http, id: &str) -> Result<Option<String>> {
180 let path = format!("/api/gen/{id}/wav_file/");
181 let body = self.api_get(http, &path).await?;
182 let data: Value = serde_json::from_slice(&body)
183 .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
184 Ok(data
185 .get("wav_file_url")
186 .and_then(Value::as_str)
187 .filter(|url| !url.is_empty())
188 .map(str::to_string))
189 }
190
191 pub async fn aligned_lyrics(&self, http: &impl Http, id: &str) -> Result<AlignedLyrics> {
206 let path = format!("/api/gen/{id}/aligned_lyrics/v2/");
207 match self.api_get_retrying(http, &path).await {
208 Ok(body) => Ok(AlignedLyrics::from_bytes(&body)),
209 Err(Error::NotFound(_)) => Ok(AlignedLyrics::default()),
210 Err(err) => Err(err),
211 }
212 }
213
214 pub async fn get_clips_by_ids(
228 &self,
229 http: &impl Http,
230 ids: &[&str],
231 concurrency: usize,
232 ) -> Result<Vec<Clip>> {
233 let mut seen: BTreeSet<&str> = BTreeSet::new();
234 let ordered: Vec<&str> = ids
235 .iter()
236 .copied()
237 .filter(|id| !id.is_empty() && seen.insert(id))
238 .collect();
239 let limit = concurrency.max(1);
240 let fetched = stream::iter(ordered.iter().copied())
241 .map(|id| async move {
242 let path = format!("/api/clip/{id}");
243 match self.api_get_retrying(http, &path).await {
244 Ok(body) => Ok(parse_clip(&body)),
245 Err(Error::NotFound(_)) => Ok(None),
246 Err(err) => Err(err),
247 }
248 })
249 .buffered(limit)
250 .collect::<Vec<_>>()
251 .await;
252 let mut clips = Vec::new();
253 for item in fetched {
254 let clip = item?;
255 if let Some(clip) = clip {
256 clips.push(clip);
257 }
258 }
259 Ok(clips)
260 }
261
262 pub async fn get_clip_parent(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
270 let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
271 match self.api_get_retrying(http, &path).await {
272 Ok(body) => Ok(parse_clip(&body)),
273 Err(Error::NotFound(_)) => Ok(None),
274 Err(err) => Err(err),
275 }
276 }
277
278 pub async fn get_playlists(&self, http: &impl Http) -> Result<Vec<Playlist>> {
291 let mut playlists = Vec::new();
292 let mut seen = BTreeSet::new();
293 for page in 1..=MAX_PAGES {
294 let path =
295 format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
296 let body = self.api_get_retrying(http, &path).await?;
297 let page_playlists = parse_playlists(&body)?;
298 if page_playlists.is_empty() {
299 break;
300 }
301 for playlist in page_playlists {
302 if seen.insert(playlist.id.clone()) {
303 playlists.push(playlist);
304 }
305 }
306 }
307 Ok(playlists)
308 }
309
310 pub async fn get_playlist_clips(
327 &self,
328 http: &impl Http,
329 id: &str,
330 ) -> Result<(Vec<Clip>, bool)> {
331 let path = format!("{PLAYLIST_PATH}{id}/");
332 let body = self.api_get_retrying(http, &path).await?;
333 parse_playlist_clips(&body)
334 }
335
336 pub async fn get_billing_info(&self, http: &impl Http) -> Result<BillingInfo> {
338 let body = self.api_get_retrying(http, BILLING_INFO_PATH).await?;
339 parse_billing_info(&body)
340 }
341
342 pub async fn list_stems(&self, http: &impl Http, clip_id: &str) -> Result<(Vec<Stem>, bool)> {
366 let declared = self.stem_page_count(http, clip_id).await?;
367 if declared == 0 {
370 return Ok((Vec::new(), false));
371 }
372 let pages = declared.min(MAX_PAGES);
373 let mut stems: Vec<Stem> = Vec::new();
374 for page in 0..pages {
375 let path = format!("/api/clip/{clip_id}/stems?page={page}");
378 let body = self.api_get_retrying(http, &path).await?;
382 stems.extend(parse_stems_page(&body));
383 }
384 dedupe_stems(&mut stems);
385 let complete = !stems.is_empty() && declared <= MAX_PAGES;
391 Ok((stems, complete))
392 }
393
394 async fn stem_page_count(&self, http: &impl Http, clip_id: &str) -> Result<u32> {
402 let path = format!("/api/clip/{clip_id}/stems/pages");
403 match self.api_get_retrying(http, &path).await {
404 Ok(body) => Ok(parse_stem_page_count(&body)),
405 Err(err) if is_invalid_page_error(&err) => Ok(0),
406 Err(Error::NotFound(_)) => Ok(0),
407 Err(err) => Err(err),
408 }
409 }
410
411 async fn try_get_clip(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
414 let path = format!("/api/clip/{id}");
415 match self.api_get_retrying(http, &path).await {
416 Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
417 Err(Error::NotFound(_)) => Ok(None),
418 Err(err) => Err(err),
419 }
420 }
421
422 async fn find_in_feed(&self, http: &impl Http, id: &str) -> Result<Clip> {
424 let (clips, _complete) = self.list_clips(http, false, None).await?;
425 clips
426 .into_iter()
427 .find(|clip| clip.id == id)
428 .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
429 }
430
431 async fn api_get(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
433 self.api_request(http, Method::Get, path, Vec::new()).await
434 }
435
436 async fn api_get_retrying(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
438 self.api_send_retrying(http, Method::Get, path, Vec::new())
439 .await
440 }
441
442 async fn api_send_retrying(
462 &self,
463 http: &impl Http,
464 method: Method,
465 path: &str,
466 body: Vec<u8>,
467 ) -> Result<Vec<u8>> {
468 let pace = self.limiter.lock().unwrap().pace(Instant::now());
469 if !pace.is_zero() {
470 self.clock.sleep(pace).await;
471 }
472 let mut retries = 0;
473 loop {
474 match self.api_request(http, method, path, body.clone()).await {
475 Ok(response) => return Ok(response),
476 Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
477 self.clock.sleep(retry_after_delay(retry_after)).await;
478 retries += 1;
479 }
480 Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
481 self.clock.sleep(backoff_delay(retries, None)).await;
482 retries += 1;
483 }
484 Err(err) => return Err(err),
485 }
486 }
487 }
488
489 async fn api_request(
494 &self,
495 http: &impl Http,
496 method: Method,
497 path: &str,
498 body: Vec<u8>,
499 ) -> Result<Vec<u8>> {
500 if method == Method::Post && !post_path_allowed(path) {
506 return Err(Error::Refused(format!(
507 "POST to {path} is not on the allow-list"
508 )));
509 }
510 let url = format!("{SUNO_API_BASE_URL}{path}");
511 let mut auth_refreshed = false;
512 loop {
513 let jwt = self.auth.ensure_jwt(self.clock.now_unix(), http).await?;
514 let mut request = match method {
515 Method::Get => HttpRequest::get(url.clone()),
516 Method::Post => HttpRequest::post(url.clone(), body.clone()),
517 };
518 request
519 .headers
520 .push(("Authorization".to_string(), format!("Bearer {jwt}")));
521 let response = http
522 .send(request)
523 .await
524 .map_err(|err| Error::Connection(err.to_string()))?;
525 match response.status {
526 200..=299 => {
527 self.limiter.lock().unwrap().on_success();
528 return Ok(response.body);
529 }
530 401 | 403 if !auth_refreshed => {
531 self.auth.invalidate_jwt();
532 auth_refreshed = true;
533 }
534 401 | 403 => {
535 return Err(Error::Auth(format!(
536 "Suno API auth failed with status {}",
537 response.status
538 )));
539 }
540 429 => {
541 self.limiter.lock().unwrap().on_rate_limit();
542 return Err(Error::RateLimited {
543 retry_after: retry_after(&response),
544 });
545 }
546 400 => {
547 let preview: String = String::from_utf8_lossy(&response.body)
548 .chars()
549 .take(200)
550 .collect();
551 return Err(Error::BadRequest(format!(
552 "Suno API returned 400: {preview}"
553 )));
554 }
555 404 => {
556 return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
557 }
558 status => {
559 let preview: String = String::from_utf8_lossy(&response.body)
560 .chars()
561 .take(200)
562 .collect();
563 return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
564 }
565 }
566 }
567 }
568}
569
570fn unwrap_clip(value: &Value) -> &Value {
573 value
574 .get("clip")
575 .filter(|clip| clip.is_object())
576 .unwrap_or(value)
577}
578
579fn post_path_allowed(path: &str) -> bool {
589 if path == FEED_V3_PATH {
590 return true;
591 }
592 if let Some(rest) = path.strip_prefix("/api/gen/")
594 && let Some(id) = rest.strip_suffix("/convert_wav/")
595 {
596 return is_single_id_segment(id);
597 }
598 false
599}
600
601fn is_single_id_segment(segment: &str) -> bool {
605 !segment.is_empty()
606 && !segment.contains('/')
607 && !segment.contains('?')
608 && !segment.contains("..")
609}
610
611fn is_invalid_page_error(err: &Error) -> bool {
616 matches!(err, Error::BadRequest(_))
617}
618
619fn parse_stem_page_count(body: &[u8]) -> u32 {
625 serde_json::from_slice::<Value>(body)
626 .ok()
627 .and_then(|data| data.get("pages").and_then(Value::as_u64))
628 .and_then(|pages| u32::try_from(pages).ok())
629 .unwrap_or(0)
630}
631
632fn parse_stems_page(body: &[u8]) -> Vec<Stem> {
642 let Ok(data) = serde_json::from_slice::<Value>(body) else {
643 return Vec::new();
644 };
645 let items = if let Some(array) = data.as_array() {
646 array.as_slice()
647 } else {
648 data.get("stems")
649 .and_then(Value::as_array)
650 .map(Vec::as_slice)
651 .unwrap_or(&[])
652 };
653 items
654 .iter()
655 .map(parse_stem)
656 .filter(|stem| !stem.id.is_empty() && !stem.url.is_empty())
657 .collect()
658}
659
660fn parse_stem(raw: &Value) -> Stem {
663 let clip = Clip::from_json(raw);
664 Stem {
665 id: clip.id.clone(),
666 label: stem_label_from_title(&clip.title),
667 url: clip.mp3_url(),
668 }
669}
670
671fn stem_label_from_title(title: &str) -> String {
676 let trimmed = title.trim_end();
677 let Some(before_close) = trimmed.strip_suffix(')') else {
678 return String::new();
679 };
680 match before_close.rfind('(') {
681 Some(open) => before_close[open + 1..].trim().to_string(),
682 None => String::new(),
683 }
684}
685
686fn dedupe_stems(stems: &mut Vec<Stem>) {
689 let mut seen = BTreeSet::new();
690 stems.retain(|stem| seen.insert(stem.url.clone()));
691}
692
693fn parse_clip(body: &[u8]) -> Option<Clip> {
696 let data: Value = serde_json::from_slice(body).ok()?;
697 let raw = unwrap_clip(&data);
698 let has_id = raw
699 .get("id")
700 .and_then(Value::as_str)
701 .is_some_and(|id| !id.is_empty());
702 has_id.then(|| Clip::from_json(raw))
703}
704
705fn parse_billing_info(body: &[u8]) -> Result<BillingInfo> {
707 let data: Value = serde_json::from_slice(body)
708 .map_err(|err| Error::Api(format!("invalid billing JSON: {err}")))?;
709 let total_credits_left = data
710 .get("total_credits_left")
711 .and_then(json_u64)
712 .ok_or_else(|| Error::Api("invalid billing JSON: missing total_credits_left".into()))?;
713 Ok(BillingInfo { total_credits_left })
714}
715
716fn json_u64(value: &Value) -> Option<u64> {
719 match value {
720 Value::Number(number) => number.as_u64(),
721 Value::String(text) => text.parse().ok(),
722 _ => None,
723 }
724}
725
726fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
733 let mut filters = serde_json::Map::new();
734 filters.insert("trashed".to_string(), Value::String("False".to_string()));
735 if liked {
736 filters.insert("liked".to_string(), Value::String("True".to_string()));
737 }
738 let mut body = serde_json::Map::new();
739 body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
740 body.insert("filters".to_string(), Value::Object(filters));
741 if let Some(cursor) = cursor {
742 body.insert("cursor".to_string(), Value::String(cursor.to_string()));
743 }
744 serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
745}
746
747fn parse_feed_v3(body: &[u8]) -> Result<(Vec<Clip>, Option<bool>, Option<String>)> {
754 let data: Value = serde_json::from_slice(body)
755 .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
756 let Some(object) = data.as_object() else {
757 return Ok((Vec::new(), None, None));
758 };
759 let clips = object
760 .get("clips")
761 .and_then(Value::as_array)
762 .map(|raw| {
763 raw.iter()
764 .map(Clip::from_json)
765 .filter(is_downloadable)
766 .collect()
767 })
768 .unwrap_or_default();
769 let has_more = object.get("has_more").and_then(Value::as_bool);
770 let next_cursor = object
771 .get("next_cursor")
772 .and_then(Value::as_str)
773 .filter(|cursor| !cursor.is_empty())
774 .map(str::to_string);
775 Ok((clips, has_more, next_cursor))
776}
777
778fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
780 let data: Value = serde_json::from_slice(body)
781 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
782 Ok(data
783 .get("playlists")
784 .and_then(Value::as_array)
785 .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
786 .unwrap_or_default())
787}
788
789fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
794 let id = raw
795 .get("id")
796 .and_then(Value::as_str)
797 .filter(|id| !id.is_empty())?
798 .to_string();
799 let name = match raw.get("name") {
800 Some(Value::String(name)) if !name.is_empty() => name.clone(),
801 _ => "Untitled".to_string(),
802 };
803 let num_clips = raw
804 .get("num_total_results")
805 .and_then(Value::as_u64)
806 .unwrap_or(0);
807 Some(Playlist {
808 id,
809 name,
810 num_clips,
811 })
812}
813
814fn parse_playlist_clips(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
832 let data: Value = serde_json::from_slice(body)
833 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
834 let raw = data.get("playlist_clips").and_then(Value::as_array);
835 let raw_len = raw.map(|a| a.len()).unwrap_or(0);
836 let clips: Vec<Clip> = raw
837 .map(|raw| {
838 raw.iter()
839 .map(|entry| Clip::from_json(unwrap_clip(entry)))
840 .filter(|clip| !clip.id.is_empty())
841 .collect()
842 })
843 .unwrap_or_default();
844 let complete = data
851 .get("num_total_results")
852 .and_then(Value::as_u64)
853 .is_some_and(|total| raw_len as u64 == total && clips.len() == raw_len);
854 Ok((clips, complete))
855}
856
857#[cfg(test)]
858mod tests {
859 use super::*;
860 use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
861 use std::time::Duration;
862
863 fn feed_body() -> String {
864 serde_json::json!({
865 "has_more": false,
866 "clips": [
867 {
868 "id": "a", "title": "Song A", "status": "complete",
869 "audio_url": "https://cdn1.suno.ai/a.mp3",
870 "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
871 },
872 {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
873 {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
874 {
875 "id": "d", "title": "Context", "status": "complete",
876 "metadata": {"type": "rendered_context_window"}
877 }
878 ]
879 })
880 .to_string()
881 }
882
883 #[test]
884 fn parse_feed_v3_filters_and_reads_pagination() {
885 let (clips, has_more, next_cursor) = parse_feed_v3(feed_body().as_bytes()).unwrap();
886 assert_eq!(has_more, Some(false));
887 assert_eq!(next_cursor, None);
888 assert_eq!(clips.len(), 1);
889 assert_eq!(clips[0].id, "a");
890 assert_eq!(clips[0].tags, "rock");
891 assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
892 }
893
894 #[test]
895 fn feed_v3_body_carries_filters_and_optional_cursor() {
896 let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
897 assert_eq!(first["filters"]["trashed"], "False");
898 assert!(first.get("cursor").is_none());
899 assert!(first["filters"].get("liked").is_none());
900
901 let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
902 assert_eq!(liked["filters"]["liked"], "True");
903 assert_eq!(liked["cursor"], "cur42");
904 }
905
906 #[test]
907 fn audiopipe_url_is_rewritten_to_cdn() {
908 let raw =
909 serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
910 assert_eq!(
911 Clip::from_json(&raw).audio_url,
912 "https://cdn1.suno.ai/x.mp3"
913 );
914 }
915
916 #[test]
917 fn list_clips_authenticates_then_reads_the_feed() {
918 let client_body = serde_json::json!({
919 "response": {
920 "last_active_session_id": "s",
921 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
922 }
923 })
924 .to_string();
925 let http = MockHttp::new(vec![
926 Rule::new(
927 "/v1/client/sessions/",
928 200,
929 r#"{"jwt": "a.b.c"}"#.to_string(),
930 ),
931 Rule::new("/v1/client", 200, client_body),
932 Rule::new("/api/feed/v3", 200, feed_body()),
933 ]);
934
935 let auth = ClerkAuth::new("eyJtoken");
936 pollster::block_on(auth.authenticate(&http)).unwrap();
937 let client = SunoClient::new(auth, RecordingClock::new());
938 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
939 assert_eq!(clips.len(), 1);
940 assert_eq!(clips[0].id, "a");
941 assert!(complete);
942 }
943
944 #[test]
945 fn api_request_uses_clock_now_unix_for_jwt_expiry() {
946 use crate::consts::JWT_REFRESH_BUFFER;
947 use base64::Engine;
948 let exp = 1_000_000i64;
949 let payload =
950 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#));
951 let jwt_str = format!("hdr.{}.sig", payload);
952 let token_body = format!(r#"{{"jwt": "{jwt_str}"}}"#);
953 let client_body = serde_json::json!({
954 "response": {
955 "last_active_session_id": "s",
956 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
957 }
958 })
959 .to_string();
960
961 let make_http = || {
962 ScriptedHttp::new()
963 .route("/v1/client/sessions/", Reply::json(&token_body))
964 .route("/v1/client", Reply::json(&client_body))
965 .route("/api/feed/v3", Reply::json(&feed_body()))
966 };
967
968 let http = make_http();
970 let auth = ClerkAuth::new("eyJtoken");
971 pollster::block_on(auth.authenticate(&http)).unwrap();
972 let client = SunoClient::new(auth, RecordingClock::at(exp - JWT_REFRESH_BUFFER));
973 let (clips, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
974 assert_eq!(clips.len(), 1);
975 assert_eq!(http.count("/v1/client/sessions/"), 2);
977
978 let http2 = make_http();
980 let auth2 = ClerkAuth::new("eyJtoken");
981 pollster::block_on(auth2.authenticate(&http2)).unwrap();
982 let client2 = SunoClient::new(auth2, RecordingClock::at(exp - JWT_REFRESH_BUFFER - 1));
983 let (clips2, _) = pollster::block_on(client2.list_clips(&http2, false, None)).unwrap();
984 assert_eq!(clips2.len(), 1);
985 assert_eq!(http2.count("/v1/client/sessions/"), 1);
987 }
988
989 #[test]
990 fn list_clips_reports_incomplete_when_paging_is_capped() {
991 let mut rules = auth_rules();
992 rules.push(Rule::new(
993 "/api/feed/v3",
994 200,
995 serde_json::json!({
996 "has_more": true,
997 "next_cursor": "cur1",
998 "clips": [{
999 "id": "a", "title": "Song A", "status": "complete",
1000 "audio_url": "https://cdn1.suno.ai/a.mp3",
1001 "metadata": {"type": "gen"}
1002 }]
1003 })
1004 .to_string(),
1005 ));
1006 let http = MockHttp::new(rules);
1007 let client = authed_client(&http);
1008
1009 let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1010 assert!(!complete);
1011 }
1012
1013 fn auth_rules() -> Vec<Rule> {
1014 let client_body = serde_json::json!({
1015 "response": {
1016 "last_active_session_id": "s",
1017 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
1018 }
1019 })
1020 .to_string();
1021 vec![
1022 Rule::new(
1023 "/v1/client/sessions/",
1024 200,
1025 r#"{"jwt": "a.b.c"}"#.to_string(),
1026 ),
1027 Rule::new("/v1/client", 200, client_body),
1028 ]
1029 }
1030
1031 fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
1032 let auth = ClerkAuth::new("eyJtoken");
1033 pollster::block_on(auth.authenticate(http)).unwrap();
1034 SunoClient::new(auth, RecordingClock::new())
1035 }
1036
1037 #[test]
1038 fn get_billing_info_reads_remaining_credits() {
1039 let mut rules = auth_rules();
1040 rules.push(Rule::new(
1041 BILLING_INFO_PATH,
1042 200,
1043 r#"{"total_credits_left":500,"monthly_limit":1000,"monthly_usage":500}"#.to_string(),
1044 ));
1045 let http = MockHttp::new(rules);
1046 let client = authed_client(&http);
1047
1048 let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
1049 assert_eq!(billing.total_credits_left, 500);
1050 }
1051
1052 #[test]
1053 fn get_billing_info_rejects_missing_balance() {
1054 let mut rules = auth_rules();
1055 rules.push(Rule::new(
1056 BILLING_INFO_PATH,
1057 200,
1058 r#"{"monthly_usage":12}"#.to_string(),
1059 ));
1060 let http = MockHttp::new(rules);
1061 let client = authed_client(&http);
1062
1063 let err = pollster::block_on(client.get_billing_info(&http)).unwrap_err();
1064 assert!(err.to_string().contains("total_credits_left"));
1065 }
1066
1067 #[test]
1068 fn aligned_lyrics_reads_words_and_lines() {
1069 let mut rules = auth_rules();
1070 let body = serde_json::json!({
1071 "aligned_words": [
1072 {"word": "hi", "success": true, "start_s": 0.5, "end_s": 0.9, "p_align": 0.99}
1073 ],
1074 "aligned_lyrics": [
1075 {"text": "hi", "start_s": 0.5, "end_s": 0.9, "section": "Verse 1",
1076 "words": [{"text": "hi", "start_s": 0.5, "end_s": 0.9}]}
1077 ],
1078 "hoot_cer": 0.2, "is_streamed": false
1079 })
1080 .to_string();
1081 rules.push(Rule::new("/aligned_lyrics/v2/", 200, body));
1082 let http = MockHttp::new(rules);
1083 let client = authed_client(&http);
1084
1085 let aligned = pollster::block_on(client.aligned_lyrics(&http, "clip-1")).unwrap();
1086 assert_eq!(aligned.words.len(), 1);
1087 assert_eq!(aligned.lines.len(), 1);
1088 assert_eq!(aligned.lines[0].section, "Verse 1");
1089 assert!(!aligned.is_empty());
1090 }
1091
1092 #[test]
1093 fn aligned_lyrics_empty_arrays_map_to_empty() {
1094 let mut rules = auth_rules();
1095 rules.push(Rule::new(
1096 "/aligned_lyrics/v2/",
1097 200,
1098 r#"{"aligned_words":[],"aligned_lyrics":[],"hoot_cer":1.0}"#.to_string(),
1099 ));
1100 let http = MockHttp::new(rules);
1101 let client = authed_client(&http);
1102
1103 let aligned = pollster::block_on(client.aligned_lyrics(&http, "instr")).unwrap();
1104 assert!(aligned.is_empty());
1105 }
1106
1107 #[test]
1108 fn aligned_lyrics_maps_404_to_empty() {
1109 let mut rules = auth_rules();
1110 rules.push(Rule::new(
1111 "/aligned_lyrics/v2/",
1112 404,
1113 "not found".to_string(),
1114 ));
1115 let http = MockHttp::new(rules);
1116 let client = authed_client(&http);
1117
1118 let aligned = pollster::block_on(client.aligned_lyrics(&http, "missing")).unwrap();
1119 assert!(aligned.is_empty());
1120 }
1121
1122 fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
1123 let auth = ClerkAuth::new("eyJtoken");
1124 pollster::block_on(auth.authenticate(http)).unwrap();
1125 SunoClient::new(auth, clock)
1126 }
1127
1128 fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
1129 let mut page = serde_json::json!({
1130 "has_more": next_cursor.is_some(),
1131 "clips": [{
1132 "id": id, "title": "Song", "status": "complete",
1133 "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
1134 "metadata": {"type": "gen"}
1135 }]
1136 });
1137 if let Some(cursor) = next_cursor {
1138 page["next_cursor"] = serde_json::json!(cursor);
1139 }
1140 page.to_string()
1141 }
1142
1143 #[test]
1144 fn list_clips_retries_a_rate_limited_page() {
1145 let http = ScriptedHttp::new().with_auth().route_seq(
1146 "/api/feed/v3",
1147 vec![Reply::status(429), Reply::json(&feed_body())],
1148 );
1149 let clock = RecordingClock::new();
1150 let client = scripted_client(&http, clock.clone());
1151
1152 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1153 assert_eq!(clips.len(), 1);
1154 assert!(complete);
1155 assert_eq!(http.count("/api/feed/v3"), 2);
1157 assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
1158 }
1159
1160 #[test]
1161 fn list_clips_honours_retry_after_on_a_throttled_page() {
1162 let http = ScriptedHttp::new().with_auth().route_seq(
1163 "/api/feed/v3",
1164 vec![
1165 Reply::status(429).with_retry_after(7),
1166 Reply::json(&feed_body()),
1167 ],
1168 );
1169 let clock = RecordingClock::new();
1170 let client = scripted_client(&http, clock.clone());
1171
1172 let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1173 assert_eq!(clips.len(), 1);
1174 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
1176 }
1177
1178 #[test]
1179 fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
1180 let http = ScriptedHttp::new().with_auth().route_seq(
1182 "/api/feed/v3",
1183 vec![
1184 Reply::json(&one_clip_page("a", Some("cur1"))),
1185 Reply::status(429),
1186 Reply::json(&one_clip_page("b", None)),
1187 ],
1188 );
1189 let clock = RecordingClock::new();
1190 let client = scripted_client(&http, clock.clone());
1191
1192 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1193 assert!(complete);
1194 assert_eq!(clips.len(), 2);
1195 let bodies = http.bodies();
1196 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
1197 assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
1198 let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
1201 let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
1202 assert_eq!(retried["cursor"], "cur1");
1203 assert_eq!(after_retry["cursor"], "cur1");
1204 }
1205
1206 #[test]
1207 fn list_clips_threads_the_cursor_across_pages() {
1208 let http = ScriptedHttp::new().with_auth().route_seq(
1209 "/api/feed/v3",
1210 vec![
1211 Reply::json(&one_clip_page("a", Some("cur1"))),
1212 Reply::json(&one_clip_page("b", None)),
1213 ],
1214 );
1215 let clock = RecordingClock::new();
1216 let client = scripted_client(&http, clock.clone());
1217
1218 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1219 assert!(complete);
1220 assert_eq!(clips.len(), 2);
1221 let bodies = http.bodies();
1222 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
1223 assert_eq!(feed_bodies.len(), 2);
1224 let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
1225 let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
1226 assert!(page1.get("cursor").is_none());
1228 assert_eq!(page2["cursor"], "cur1");
1229 }
1230
1231 #[test]
1232 fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
1233 let page = serde_json::json!({
1236 "has_more": true,
1237 "clips": [{
1238 "id": "a", "title": "Song", "status": "complete",
1239 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
1240 }]
1241 })
1242 .to_string();
1243 let http = ScriptedHttp::new()
1244 .with_auth()
1245 .route("/api/feed/v3", Reply::json(&page));
1246 let clock = RecordingClock::new();
1247 let client = scripted_client(&http, clock.clone());
1248
1249 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1250 assert!(!complete);
1251 assert_eq!(clips.len(), 1);
1252 assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
1253 }
1254
1255 #[test]
1256 fn list_clips_is_incomplete_when_has_more_is_missing() {
1257 let page = serde_json::json!({
1259 "clips": [{
1260 "id": "a", "title": "Song", "status": "complete",
1261 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
1262 }]
1263 })
1264 .to_string();
1265 let http = ScriptedHttp::new()
1266 .with_auth()
1267 .route("/api/feed/v3", Reply::json(&page));
1268 let clock = RecordingClock::new();
1269 let client = scripted_client(&http, clock.clone());
1270
1271 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1272 assert!(!complete);
1273 assert_eq!(clips.len(), 1);
1274 assert_eq!(http.count("/api/feed/v3"), 1);
1275 }
1276
1277 #[test]
1278 fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
1279 let http = ScriptedHttp::new().with_auth().route_seq(
1280 "/api/feed/v3",
1281 vec![
1282 Reply::json(&one_clip_page("a", Some("cur1"))),
1283 Reply::status(500),
1284 ],
1285 );
1286 let clock = RecordingClock::new();
1287 let client = scripted_client(&http, clock.clone());
1288
1289 let result = pollster::block_on(client.list_clips(&http, false, None));
1290 assert!(matches!(result, Err(Error::Api(_))));
1291 }
1292
1293 #[test]
1294 fn list_clips_is_complete_on_an_empty_drained_feed() {
1295 let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
1298 let http = ScriptedHttp::new()
1299 .with_auth()
1300 .route("/api/feed/v3", Reply::json(&page));
1301 let clock = RecordingClock::new();
1302 let client = scripted_client(&http, clock.clone());
1303
1304 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1305 assert!(complete);
1306 assert!(clips.is_empty());
1307 }
1308
1309 #[test]
1310 fn list_clips_liked_scope_sends_the_liked_filter() {
1311 let http = ScriptedHttp::new()
1312 .with_auth()
1313 .route("/api/feed/v3", Reply::json(&feed_body()));
1314 let clock = RecordingClock::new();
1315 let client = scripted_client(&http, clock.clone());
1316
1317 let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
1318 let bodies = http.bodies();
1319 let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
1320 let value: Value = serde_json::from_str(feed_body).unwrap();
1321 assert_eq!(value["filters"]["liked"], "True");
1322 assert_eq!(value["filters"]["trashed"], "False");
1323 }
1324
1325 #[test]
1326 fn list_clips_does_not_pace_an_unthrottled_walk() {
1327 let http = ScriptedHttp::new().with_auth().route_seq(
1328 "/api/feed/v3",
1329 vec![
1330 Reply::json(&one_clip_page("a", Some("cur1"))),
1331 Reply::json(&one_clip_page("e", None)),
1332 ],
1333 );
1334 let clock = RecordingClock::new();
1335 let client = scripted_client(&http, clock.clone());
1336
1337 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1338 assert!(complete);
1339 assert_eq!(clips.len(), 2);
1340 assert_eq!(http.count("/api/feed/v3"), 2);
1341 assert!(clock.sleeps().is_empty());
1343 }
1344
1345 #[test]
1346 fn list_clips_slows_its_pace_after_a_throttled_page() {
1347 let http = ScriptedHttp::new().with_auth().route_seq(
1348 "/api/feed/v3",
1349 vec![
1350 Reply::status(429),
1351 Reply::json(&one_clip_page("a", Some("cur1"))),
1352 Reply::json(&one_clip_page("e", None)),
1353 ],
1354 );
1355 let clock = RecordingClock::new();
1356 let client = scripted_client(&http, clock.clone());
1357
1358 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1359 assert!(complete);
1360 assert_eq!(clips.len(), 2);
1361 assert_eq!(
1364 clock.sleeps(),
1365 vec![Duration::from_secs(5), Duration::from_secs(1)]
1366 );
1367 }
1368
1369 #[test]
1370 fn list_clips_gives_up_after_max_retries() {
1371 let http = ScriptedHttp::new()
1372 .with_auth()
1373 .route("/api/feed/v3", Reply::status(429));
1374 let clock = RecordingClock::new();
1375 let client = scripted_client(&http, clock.clone());
1376
1377 let result = pollster::block_on(client.list_clips(&http, false, None));
1378 assert!(matches!(result, Err(Error::RateLimited { .. })));
1379 let budget = crate::consts::API_MAX_RETRIES as usize;
1380 assert_eq!(clock.sleeps().len(), budget);
1381 assert_eq!(http.count("/api/feed/v3"), budget + 1);
1382 }
1383
1384 #[test]
1385 fn parse_clip_accepts_bare_and_wrapped_shapes() {
1386 let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
1387 assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
1388
1389 let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
1390 assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
1391
1392 let missing = serde_json::json!({"detail": "not found"}).to_string();
1393 assert!(parse_clip(missing.as_bytes()).is_none());
1394 }
1395
1396 #[test]
1397 fn get_clip_uses_the_dedicated_endpoint() {
1398 let clip_body = serde_json::json!({
1399 "id": "z", "title": "Zed", "status": "complete",
1400 "audio_url": "https://cdn1.suno.ai/z.mp3",
1401 "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
1402 })
1403 .to_string();
1404 let mut rules = auth_rules();
1405 rules.push(Rule::new("/api/clip/", 200, clip_body));
1406 let http = MockHttp::new(rules);
1407 let client = authed_client(&http);
1408
1409 let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
1410 assert_eq!(clip.id, "z");
1411 assert_eq!(clip.title, "Zed");
1412 assert_eq!(clip.tags, "jazz");
1413 }
1414
1415 #[test]
1416 fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
1417 let mut rules = auth_rules();
1418 rules.push(Rule::new(
1419 "/api/clip/",
1420 404,
1421 r#"{"detail": "not found"}"#.to_string(),
1422 ));
1423 rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
1424 let http = MockHttp::new(rules);
1425 let client = authed_client(&http);
1426
1427 let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
1428 assert_eq!(clip.id, "a");
1429 assert_eq!(clip.tags, "rock");
1430 }
1431
1432 #[test]
1433 fn request_wav_accepts_a_2xx_status() {
1434 let mut rules = auth_rules();
1435 rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
1436 let http = MockHttp::new(rules);
1437 let client = authed_client(&http);
1438
1439 assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
1440 }
1441
1442 #[test]
1443 fn wav_url_reads_the_ready_url() {
1444 let mut rules = auth_rules();
1445 rules.push(Rule::new(
1446 "/wav_file/",
1447 200,
1448 r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
1449 ));
1450 let http = MockHttp::new(rules);
1451 let client = authed_client(&http);
1452
1453 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1454 assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
1455 }
1456
1457 #[test]
1458 fn wav_url_is_none_until_the_render_is_ready() {
1459 let mut rules = auth_rules();
1460 rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
1461 let http = MockHttp::new(rules);
1462 let client = authed_client(&http);
1463
1464 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1465 assert_eq!(url, None);
1466 }
1467
1468 #[test]
1469 fn get_clips_by_ids_fetches_each_id_and_keeps_artefacts() {
1470 let p1 = serde_json::json!({
1474 "id": "p1", "title": "Infill Ancestor", "status": "complete",
1475 "metadata": {"type": "gen", "task": "infill"}
1476 })
1477 .to_string();
1478 let p2 = serde_json::json!({
1479 "id": "p2", "title": "Uploaded Root", "status": "complete",
1480 "metadata": {"type": "upload"}
1481 })
1482 .to_string();
1483 let mut rules = auth_rules();
1484 rules.push(Rule::new("/api/clip/p1", 200, p1));
1485 rules.push(Rule::new("/api/clip/p2", 200, p2));
1486 let http = MockHttp::new(rules);
1487 let client = authed_client(&http);
1488
1489 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"], 4)).unwrap();
1490 assert_eq!(
1491 clips.len(),
1492 2,
1493 "infill and upload ancestors must not be filtered"
1494 );
1495 assert_eq!(clips[0].id, "p1");
1496 assert_eq!(clips[1].id, "p2");
1497 }
1498
1499 #[test]
1500 fn get_clips_by_ids_returns_a_trashed_clip() {
1501 let trashed = serde_json::json!({
1504 "id": "t1", "title": "Trashed Ancestor", "status": "complete",
1505 "is_trashed": true, "metadata": {"type": "gen"}
1506 })
1507 .to_string();
1508 let mut rules = auth_rules();
1509 rules.push(Rule::new("/api/clip/t1", 200, trashed));
1510 let http = MockHttp::new(rules);
1511 let client = authed_client(&http);
1512
1513 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"], 4)).unwrap();
1514 assert_eq!(clips.len(), 1);
1515 assert_eq!(clips[0].id, "t1");
1516 assert!(clips[0].is_trashed);
1517 }
1518
1519 #[test]
1520 fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
1521 let only = serde_json::json!({
1522 "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
1523 })
1524 .to_string();
1525 let http = ScriptedHttp::new()
1526 .with_auth()
1527 .route("/api/clip/gone", Reply::status(404))
1528 .route("/api/clip/only", Reply::json(&only));
1529 let client = scripted_client(&http, RecordingClock::new());
1530
1531 let clips =
1532 pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"], 4))
1533 .unwrap();
1534 assert_eq!(clips.len(), 1, "the 404 id is skipped");
1535 assert_eq!(clips[0].id, "only");
1536 assert_eq!(http.count("/api/clip/only"), 1);
1538 assert_eq!(http.count("/api/clip/gone"), 1);
1539 }
1540
1541 #[test]
1542 fn get_clips_by_ids_matches_serial_results_and_keeps_order_when_concurrent() {
1543 let a = serde_json::json!({
1544 "id": "a", "title": "A", "status": "complete", "metadata": {"type": "gen"}
1545 })
1546 .to_string();
1547 let b = serde_json::json!({
1548 "id": "b", "title": "B", "status": "complete", "metadata": {"type": "gen"}
1549 })
1550 .to_string();
1551 let c = serde_json::json!({
1552 "id": "c", "title": "C", "status": "complete", "metadata": {"type": "gen"}
1553 })
1554 .to_string();
1555 let http = ScriptedHttp::new()
1556 .with_auth()
1557 .route("/api/clip/a", Reply::json(&a))
1558 .route("/api/clip/b", Reply::json(&b))
1559 .route("/api/clip/c", Reply::json(&c));
1560 let client = scripted_client(&http, RecordingClock::new());
1561 let ids = ["b", "a", "c", "a"];
1562
1563 let serial = pollster::block_on(client.get_clips_by_ids(&http, &ids, 1)).unwrap();
1564 let concurrent = pollster::block_on(client.get_clips_by_ids(&http, &ids, 4)).unwrap();
1565
1566 let serial_ids: Vec<&str> = serial.iter().map(|clip| clip.id.as_str()).collect();
1567 let concurrent_ids: Vec<&str> = concurrent.iter().map(|clip| clip.id.as_str()).collect();
1568 assert_eq!(serial_ids, vec!["b", "a", "c"]);
1569 assert_eq!(concurrent_ids, serial_ids);
1570 }
1571
1572 #[test]
1573 fn concurrent_reads_share_aggregate_pacing_after_first_rate_limit() {
1574 const EXPECTED_SPAN: Duration = Duration::from_secs(3);
1577 const TOLERANCE: Duration = Duration::from_millis(50);
1578 let ids = ["a", "b", "c", "d"];
1579 let a =
1580 serde_json::json!({"id":"a","title":"A","status":"complete","metadata":{"type":"gen"}})
1581 .to_string();
1582 let b =
1583 serde_json::json!({"id":"b","title":"B","status":"complete","metadata":{"type":"gen"}})
1584 .to_string();
1585 let c =
1586 serde_json::json!({"id":"c","title":"C","status":"complete","metadata":{"type":"gen"}})
1587 .to_string();
1588 let d =
1589 serde_json::json!({"id":"d","title":"D","status":"complete","metadata":{"type":"gen"}})
1590 .to_string();
1591 let http = ScriptedHttp::new()
1592 .with_auth()
1593 .route_seq(
1594 "/api/feed/v3",
1595 vec![
1596 Reply::status(429),
1597 Reply::json(&one_clip_page("seed", None)),
1598 ],
1599 )
1600 .route("/api/clip/a", Reply::json(&a))
1601 .route("/api/clip/b", Reply::json(&b))
1602 .route("/api/clip/c", Reply::json(&c))
1603 .route("/api/clip/d", Reply::json(&d));
1604 let clock = RecordingClock::new();
1605 let client = scripted_client(&http, clock.clone());
1606 pollster::block_on(client.list_clips(&http, false, Some(1))).unwrap();
1607 let before = clock.sleeps().len();
1608
1609 let clips = pollster::block_on(client.get_clips_by_ids(&http, &ids, ids.len())).unwrap();
1610 assert_eq!(clips.len(), ids.len());
1611 let sleeps = clock.sleeps();
1612 let paced = &sleeps[before..];
1613 assert_eq!(paced.len(), ids.len());
1614 let min = paced.iter().copied().min().unwrap();
1615 let max = paced.iter().copied().max().unwrap();
1616 let span = max.saturating_sub(min);
1617 assert!(span >= EXPECTED_SPAN.saturating_sub(TOLERANCE));
1621 assert!(span <= EXPECTED_SPAN + TOLERANCE);
1622 }
1623
1624 #[test]
1625 fn get_clip_parent_reads_the_parent_clip() {
1626 let parent = serde_json::json!({
1627 "id": "par", "title": "Ancestor", "status": "complete",
1628 "metadata": {"type": "gen"}
1629 })
1630 .to_string();
1631 let mut rules = auth_rules();
1632 rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
1633 let http = MockHttp::new(rules);
1634 let client = authed_client(&http);
1635
1636 let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
1637 assert_eq!(clip.unwrap().id, "par");
1638 }
1639
1640 #[test]
1641 fn get_clip_parent_is_none_for_a_root() {
1642 let mut rules = auth_rules();
1643 rules.push(Rule::new(
1644 "/api/clips/parent",
1645 404,
1646 r#"{"detail": "no parent"}"#.to_string(),
1647 ));
1648 let http = MockHttp::new(rules);
1649 let client = authed_client(&http);
1650
1651 let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
1652 assert!(clip.is_none());
1653 }
1654
1655 #[test]
1656 fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
1657 for status in [500u16, 503] {
1661 let mut rules = auth_rules();
1662 rules.push(Rule::new(
1663 "/api/clips/parent",
1664 status,
1665 r#"{"detail": "server error"}"#.to_string(),
1666 ));
1667 let http = MockHttp::new(rules);
1668 let client = authed_client(&http);
1669
1670 let result = pollster::block_on(client.get_clip_parent(&http, "child"));
1671 assert!(
1672 matches!(result, Err(Error::Api(_))),
1673 "status {status} must propagate as an error, not Ok(None)"
1674 );
1675 }
1676 }
1677
1678 #[test]
1679 fn get_playlists_maps_entries_and_skips_missing_ids() {
1680 let page1 = serde_json::json!({
1681 "playlists": [
1682 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
1683 {"id": "", "name": "No Id", "num_total_results": 3},
1684 {"name": "Also No Id"}
1685 ]
1686 })
1687 .to_string();
1688 let mut rules = auth_rules();
1689 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1691 rules.push(Rule::new(
1692 "/api/playlist/me?page=2",
1693 200,
1694 r#"{"playlists": []}"#.to_string(),
1695 ));
1696 let http = MockHttp::new(rules);
1697 let client = authed_client(&http);
1698
1699 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1700 assert_eq!(playlists.len(), 1, "entries without an id are dropped");
1701 assert_eq!(
1702 playlists[0],
1703 Playlist {
1704 id: "pl1".to_owned(),
1705 name: "Road Trip".to_owned(),
1706 num_clips: 12,
1707 }
1708 );
1709 }
1710
1711 #[test]
1712 fn get_playlists_defaults_a_missing_name_to_untitled() {
1713 let page1 = serde_json::json!({
1714 "playlists": [{"id": "pl9", "num_total_results": 1}]
1715 })
1716 .to_string();
1717 let mut rules = auth_rules();
1718 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1719 rules.push(Rule::new(
1720 "/api/playlist/me?page=2",
1721 200,
1722 r#"{"playlists": []}"#.to_string(),
1723 ));
1724 let http = MockHttp::new(rules);
1725 let client = authed_client(&http);
1726
1727 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1728 assert_eq!(playlists[0].name, "Untitled");
1729 }
1730
1731 #[test]
1732 fn get_playlist_clips_preserves_order_and_unwraps_clip() {
1733 let body = serde_json::json!({
1736 "num_total_results": 2,
1737 "playlist_clips": [
1738 {"clip": {
1739 "id": "second", "title": "Second", "status": "complete",
1740 "metadata": {"duration": 60.0, "type": "gen"}
1741 }},
1742 {"clip": {
1743 "id": "first", "title": "First", "status": "complete",
1744 "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
1745 }}
1746 ]
1747 })
1748 .to_string();
1749 let mut rules = auth_rules();
1750 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1751 let http = MockHttp::new(rules);
1752 let client = authed_client(&http);
1753
1754 let (clips, complete) =
1755 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1756 assert_eq!(clips.len(), 2, "an infill member is not filtered out");
1757 assert_eq!(clips[0].id, "second");
1758 assert_eq!(clips[1].id, "first");
1759 assert!(
1760 complete,
1761 "returned == num_total_results is fully enumerated"
1762 );
1763 }
1764
1765 #[test]
1766 fn get_playlist_clips_short_page_is_not_complete() {
1767 let body = serde_json::json!({
1769 "num_total_results": 5,
1770 "playlist_clips": [
1771 {"clip": {
1772 "id": "only", "title": "Only", "status": "complete",
1773 "metadata": {"duration": 60.0, "type": "gen"}
1774 }}
1775 ]
1776 })
1777 .to_string();
1778 let mut rules = auth_rules();
1779 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1780 let http = MockHttp::new(rules);
1781 let client = authed_client(&http);
1782
1783 let (clips, complete) =
1784 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1785 assert_eq!(clips.len(), 1);
1786 assert!(!complete, "a short page is not fully enumerated");
1787 }
1788
1789 #[test]
1790 fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
1791 let mut rules = auth_rules();
1792 rules.push(Rule::new(
1793 "/api/playlist/empty/",
1794 200,
1795 r#"{"num_total_results": 0, "playlist_clips": []}"#.to_string(),
1796 ));
1797 let http = MockHttp::new(rules);
1798 let client = authed_client(&http);
1799
1800 let (clips, complete) =
1801 pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
1802 assert!(clips.is_empty());
1803 assert!(
1804 complete,
1805 "an empty playlist reporting zero total is complete"
1806 );
1807 }
1808
1809 #[test]
1810 fn get_playlist_clips_missing_total_is_not_complete() {
1811 let mut rules = auth_rules();
1815 rules.push(Rule::new(
1816 "/api/playlist/pl1/",
1817 200,
1818 r#"{"playlist_clips": []}"#.to_string(),
1819 ));
1820 let http = MockHttp::new(rules);
1821 let client = authed_client(&http);
1822
1823 let (clips, complete) =
1824 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1825 assert!(clips.is_empty());
1826 assert!(!complete, "a missing total is never fully enumerated");
1827 }
1828
1829 #[test]
1830 fn get_playlist_clips_dropped_member_disarms_authority() {
1831 let missing_id = serde_json::json!({
1836 "num_total_results": 2,
1837 "playlist_clips": [
1838 {"clip": {
1839 "id": "a", "title": "A", "status": "complete",
1840 "metadata": {"duration": 60.0, "type": "gen"}
1841 }},
1842 {"clip": {
1843 "title": "No Id", "status": "complete",
1844 "metadata": {"duration": 30.0, "type": "gen"}
1845 }}
1846 ]
1847 })
1848 .to_string();
1849 let empty_id = serde_json::json!({
1850 "num_total_results": 2,
1851 "playlist_clips": [
1852 {"clip": {
1853 "id": "a", "title": "A", "status": "complete",
1854 "metadata": {"duration": 60.0, "type": "gen"}
1855 }},
1856 {"clip": {
1857 "id": "", "title": "Empty Id", "status": "complete",
1858 "metadata": {"duration": 30.0, "type": "gen"}
1859 }}
1860 ]
1861 })
1862 .to_string();
1863 for body in [missing_id, empty_id] {
1864 let mut rules = auth_rules();
1865 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1866 let http = MockHttp::new(rules);
1867 let client = authed_client(&http);
1868
1869 let (clips, complete) =
1870 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1871 assert_eq!(clips.len(), 1, "the member with no id is dropped");
1872 assert!(
1873 !complete,
1874 "a dropped member disarms authority even when raw_len == total"
1875 );
1876 }
1877 }
1878
1879 #[test]
1880 fn get_playlist_clips_over_count_is_not_complete() {
1881 let body = serde_json::json!({
1886 "num_total_results": 2,
1887 "playlist_clips": [
1888 {"clip": {
1889 "id": "a", "title": "A", "status": "complete",
1890 "metadata": {"duration": 60.0, "type": "gen"}
1891 }},
1892 {"clip": {
1893 "id": "b", "title": "B", "status": "complete",
1894 "metadata": {"duration": 30.0, "type": "gen"}
1895 }},
1896 {"clip": {
1897 "id": "", "title": "Empty Id", "status": "complete",
1898 "metadata": {"duration": 45.0, "type": "gen"}
1899 }}
1900 ]
1901 })
1902 .to_string();
1903 let mut rules = auth_rules();
1904 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1905 let http = MockHttp::new(rules);
1906 let client = authed_client(&http);
1907
1908 let (clips, complete) =
1909 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1910 assert_eq!(clips.len(), 2, "the empty-id member is dropped");
1911 assert!(
1912 !complete,
1913 "raw_len (3) diverging from the total (2) is not authoritative"
1914 );
1915 }
1916
1917 #[test]
1918 fn get_playlist_clips_ignores_song_count() {
1919 let body = serde_json::json!({
1923 "num_total_results": 1,
1924 "song_count": 0,
1925 "playlist_clips": [
1926 {"clip": {
1927 "id": "only", "title": "Only", "status": "complete",
1928 "metadata": {"duration": 60.0, "type": "gen"}
1929 }}
1930 ]
1931 })
1932 .to_string();
1933 let mut rules = auth_rules();
1934 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1935 let http = MockHttp::new(rules);
1936 let client = authed_client(&http);
1937
1938 let (clips, complete) =
1939 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1940 assert_eq!(clips.len(), 1);
1941 assert!(
1942 complete,
1943 "completeness uses num_total_results, not song_count"
1944 );
1945 }
1946
1947 #[test]
1948 fn get_playlists_num_clips_ignores_song_count() {
1949 let page1 = serde_json::json!({
1952 "playlists": [
1953 {"id": "pl1", "name": "Road Trip", "num_total_results": 15, "song_count": 0}
1954 ]
1955 })
1956 .to_string();
1957 let mut rules = auth_rules();
1958 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1959 rules.push(Rule::new(
1960 "/api/playlist/me?page=2",
1961 200,
1962 r#"{"playlists": []}"#.to_string(),
1963 ));
1964 let http = MockHttp::new(rules);
1965 let client = authed_client(&http);
1966
1967 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1968 assert_eq!(
1969 playlists[0].num_clips, 15,
1970 "num_clips reads num_total_results, not song_count"
1971 );
1972 }
1973
1974 #[test]
1975 fn get_playlists_dedupes_a_page_ignoring_server() {
1976 let same_body = serde_json::json!({
1981 "playlists": [
1982 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
1983 {"id": "pl2", "name": "Chill", "num_total_results": 7}
1984 ]
1985 })
1986 .to_string();
1987 let mut rules = auth_rules();
1988 rules.push(Rule::new("/api/playlist/me", 200, same_body));
1989 let http = MockHttp::new(rules);
1990 let client = authed_client(&http);
1991
1992 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1993 assert_eq!(
1994 playlists.len(),
1995 2,
1996 "duplicates from a page-ignoring server are collapsed"
1997 );
1998 assert_eq!(playlists[0].id, "pl1");
1999 assert_eq!(playlists[1].id, "pl2");
2000 }
2001
2002 #[test]
2003 fn get_playlist_clips_preserves_array_order_over_created_at() {
2004 let body = serde_json::json!({
2008 "num_total_results": 3,
2009 "playlist_clips": [
2010 {"clip": {
2011 "id": "a", "title": "A", "status": "complete",
2012 "metadata": {"duration": 60.0, "type": "gen"}
2013 }, "relative_index": 1.0, "created_at": "2026-06-08T00:00:00.000Z"},
2014 {"clip": {
2015 "id": "b", "title": "B", "status": "complete",
2016 "metadata": {"duration": 30.0, "type": "gen"}
2017 }, "relative_index": 2.0, "created_at": "2026-01-11T00:00:00.000Z"},
2018 {"clip": {
2019 "id": "c", "title": "C", "status": "complete",
2020 "metadata": {"duration": 45.0, "type": "gen"}
2021 }, "relative_index": 3.0, "created_at": "2026-05-15T00:00:00.000Z"}
2022 ]
2023 })
2024 .to_string();
2025 let mut rules = auth_rules();
2026 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
2027 let http = MockHttp::new(rules);
2028 let client = authed_client(&http);
2029
2030 let (clips, complete) =
2031 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
2032 assert_eq!(
2033 clips.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(),
2034 ["a", "b", "c"],
2035 "array order is preserved despite non-monotonic created_at"
2036 );
2037 assert!(complete, "three intact members equal the declared total");
2038 }
2039
2040 fn stem_page(stems: &[(&str, &str, &str)]) -> String {
2043 let entries: Vec<Value> = stems
2044 .iter()
2045 .map(|(id, label, url)| {
2046 serde_json::json!({
2047 "id": id,
2048 "title": format!("My Song ({label})"),
2049 "status": "complete",
2050 "audio_url": url,
2051 })
2052 })
2053 .collect();
2054 serde_json::json!({ "stems": entries }).to_string()
2055 }
2056
2057 fn stem_pages(pages: u32) -> String {
2059 serde_json::json!({ "pages": pages }).to_string()
2060 }
2061
2062 #[test]
2063 fn list_stems_drains_all_declared_pages_and_is_authoritative() {
2064 let http = ScriptedHttp::new()
2067 .with_auth()
2068 .route("stems/pages", Reply::json(&stem_pages(2)))
2069 .route(
2070 "stems?page=0",
2071 Reply::json(&stem_page(&[
2072 ("s1", "Vocals", "https://cdn1.suno.ai/s1.mp3"),
2073 ("s2", "Drums", "https://cdn1.suno.ai/s2.mp3"),
2074 ])),
2075 )
2076 .route(
2077 "stems?page=1",
2078 Reply::json(&stem_page(&[("s3", "Bass", "https://cdn1.suno.ai/s3.mp3")])),
2079 );
2080 let client = scripted_client(&http, RecordingClock::new());
2081
2082 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
2083 assert_eq!(stems.len(), 3);
2084 assert_eq!(stems[0].id, "s1");
2085 assert_eq!(stems[0].label, "Vocals");
2086 assert_eq!(stems[0].url, "https://cdn1.suno.ai/s1.mp3");
2087 assert_eq!(stems[2].label, "Bass");
2088 assert!(
2089 complete,
2090 "a fully drained listing that returned stems is authoritative"
2091 );
2092 }
2093
2094 #[test]
2095 fn list_stems_zero_pages_is_indeterminate_never_empty() {
2096 let http = ScriptedHttp::new()
2099 .with_auth()
2100 .route("stems/pages", Reply::json(&stem_pages(0)));
2101 let client = scripted_client(&http, RecordingClock::new());
2102
2103 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
2104 assert!(stems.is_empty());
2105 assert!(
2106 !complete,
2107 "an empty listing is indeterminate, so existing stems are kept"
2108 );
2109 }
2110
2111 #[test]
2112 fn list_stems_missing_page_count_is_indeterminate() {
2113 for status in [400u16, 404] {
2116 let http = ScriptedHttp::new()
2117 .with_auth()
2118 .route("stems/pages", Reply::status(status));
2119 let client = scripted_client(&http, RecordingClock::new());
2120 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
2121 assert!(stems.is_empty(), "status {status}");
2122 assert!(!complete, "status {status} is indeterminate, not empty");
2123 }
2124 }
2125
2126 #[test]
2127 fn stem_page_count_5xx_with_invalid_page_body_is_not_no_stems() {
2128 let http = ScriptedHttp::new()
2132 .with_auth()
2133 .route("stems/pages", Reply::with_body(500, "Invalid page"));
2134 let client = scripted_client(&http, RecordingClock::new());
2135
2136 let result = pollster::block_on(client.list_stems(&http, "clip1"));
2137 assert!(
2138 result.is_err(),
2139 "a 5xx is a transient error, never 'no stems'"
2140 );
2141 }
2142
2143 #[test]
2144 fn list_stems_page_error_mid_enumeration_propagates() {
2145 let http = ScriptedHttp::new()
2149 .with_auth()
2150 .route("stems/pages", Reply::json(&stem_pages(2)))
2151 .route(
2152 "stems?page=0",
2153 Reply::json(&stem_page(&[(
2154 "s1",
2155 "Vocals",
2156 "https://cdn1.suno.ai/s1.mp3",
2157 )])),
2158 )
2159 .route("stems?page=1", Reply::status(500));
2160 let client = scripted_client(&http, RecordingClock::new());
2161
2162 let result = pollster::block_on(client.list_stems(&http, "clip1"));
2163 assert!(result.is_err(), "a 5xx page is not a clean drain");
2164 }
2165
2166 #[test]
2167 fn list_stems_over_max_pages_is_truncated_never_authoritative() {
2168 let http = ScriptedHttp::new()
2173 .with_auth()
2174 .route("stems/pages", Reply::json(&stem_pages(MAX_PAGES + 1)))
2175 .route(
2176 "stems?page=",
2177 Reply::json(&stem_page(&[(
2178 "s1",
2179 "Vocals",
2180 "https://cdn1.suno.ai/s1.mp3",
2181 )])),
2182 );
2183 let client = scripted_client(&http, RecordingClock::new());
2184
2185 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
2186 assert!(!stems.is_empty(), "the fetched pages still yield stems");
2187 assert!(
2188 !complete,
2189 "a listing declaring more than MAX_PAGES is truncated, never authoritative"
2190 );
2191 }
2192
2193 #[test]
2194 fn parse_stems_page_maps_full_clips_and_skips_idless() {
2195 let page = stem_page(&[("x", "Backing Vocals", "https://cdn1.suno.ai/x.mp3")]);
2198 let stems = parse_stems_page(page.as_bytes());
2199 assert_eq!(stems.len(), 1);
2200 assert_eq!(stems[0].id, "x");
2201 assert_eq!(stems[0].label, "Backing Vocals");
2202 assert_eq!(stems[0].url, "https://cdn1.suno.ai/x.mp3");
2203 let no_id = br#"{"stems": [{"title": "Ghost (Vocals)", "audio_url": "https://cdn1.suno.ai/g.mp3"}]}"#;
2205 assert!(parse_stems_page(no_id).is_empty());
2206 let no_url = br#"{"stems": [{"id": "y", "title": "Song (Bass)"}]}"#;
2209 let recovered = parse_stems_page(no_url);
2210 assert_eq!(recovered.len(), 1);
2211 assert_eq!(recovered[0].url, "https://cdn1.suno.ai/y.mp3");
2212 assert!(parse_stems_page(b"not json").is_empty());
2214 }
2215
2216 #[test]
2217 fn parse_stem_page_count_reads_pages_field() {
2218 assert_eq!(parse_stem_page_count(br#"{"pages": 12}"#), 12);
2219 assert_eq!(parse_stem_page_count(br#"{"pages": 0}"#), 0);
2220 assert_eq!(parse_stem_page_count(br#"{}"#), 0);
2222 assert_eq!(parse_stem_page_count(br#"{"pages": -1}"#), 0);
2223 assert_eq!(parse_stem_page_count(b"not json"), 0);
2224 }
2225
2226 #[test]
2227 fn stem_label_from_title_extracts_trailing_parenthetical() {
2228 assert_eq!(stem_label_from_title("My Song (Vocals)"), "Vocals");
2229 assert_eq!(
2230 stem_label_from_title("A (b) Song (Backing Vocals)"),
2231 "Backing Vocals"
2232 );
2233 assert_eq!(stem_label_from_title("My Song (Drums) "), "Drums");
2234 assert_eq!(stem_label_from_title("My Song"), "");
2236 assert_eq!(stem_label_from_title(""), "");
2237 }
2238
2239 #[test]
2240 fn post_allow_list_permits_only_feed_and_wav_render() {
2241 assert!(post_path_allowed(FEED_V3_PATH));
2242 assert!(post_path_allowed("/api/gen/abc123/convert_wav/"));
2243 assert!(!post_path_allowed("/api/gen/abc123/stem_task"));
2245 assert!(!post_path_allowed("/api/gen/abc123/separate"));
2246 assert!(!post_path_allowed("/api/gen/a/../evil/convert_wav/"));
2248 assert!(!post_path_allowed("/api/gen/a/b/convert_wav/"));
2249 assert!(!post_path_allowed("/api/clip/x/stems/pages"));
2251 assert!(!post_path_allowed("/api/clip/x/stems?page=0"));
2252 }
2253
2254 #[test]
2255 fn api_request_refuses_a_post_off_the_allow_list() {
2256 let http = MockHttp::new(auth_rules());
2259 let client = authed_client(&http);
2260 let err = pollster::block_on(client.api_request(
2261 &http,
2262 Method::Post,
2263 "/api/gen/x/stem_task",
2264 b"{}".to_vec(),
2265 ))
2266 .unwrap_err();
2267 assert!(matches!(err, Error::Refused(_)));
2268 }
2269}