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
44#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Stem {
55 pub id: String,
58 pub label: String,
62 pub url: String,
64}
65
66pub struct SunoClient<C> {
75 auth: ClerkAuth,
76 clock: C,
77 limiter: AdaptiveLimiter,
78}
79
80impl<C: Clock> SunoClient<C> {
81 pub fn new(auth: ClerkAuth, clock: C) -> Self {
83 Self {
84 auth,
85 clock,
86 limiter: AdaptiveLimiter::new(FEED_INITIAL_RATE),
87 }
88 }
89
90 pub fn auth(&self) -> &ClerkAuth {
92 &self.auth
93 }
94
95 #[cfg(test)]
99 pub(crate) fn limiter_rate(&self) -> f64 {
100 self.limiter.rate()
101 }
102
103 pub async fn list_clips(
119 &mut self,
120 http: &impl Http,
121 liked: bool,
122 limit: Option<usize>,
123 ) -> Result<(Vec<Clip>, bool)> {
124 let mut clips = Vec::new();
125 let mut cursor: Option<String> = None;
126 let mut complete = false;
127 for _ in 0..MAX_PAGES {
128 let body = feed_v3_body(liked, cursor.as_deref());
129 let response = self
130 .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
131 .await?;
132 let (page_clips, has_more, next_cursor) = parse_feed_v3(&response)?;
133 clips.extend(page_clips);
134 match has_more {
135 Some(false) => {
136 complete = true;
137 break;
138 }
139 Some(true) => match next_cursor {
140 Some(next) => cursor = Some(next),
141 None => break,
142 },
143 None => break,
144 }
145 if limit.is_some_and(|n| clips.len() >= n) {
146 break;
147 }
148 }
149 if let Some(n) = limit {
150 clips.truncate(n);
151 }
152 Ok((clips, complete))
153 }
154
155 pub async fn get_clip(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
161 if let Some(clip) = self.try_get_clip(http, id).await? {
162 return Ok(clip);
163 }
164 self.find_in_feed(http, id).await
165 }
166
167 pub async fn request_wav(&mut self, http: &impl Http, id: &str) -> Result<()> {
169 let path = format!("/api/gen/{id}/convert_wav/");
170 self.api_request(http, Method::Post, &path, Vec::new())
171 .await?;
172 Ok(())
173 }
174
175 pub async fn wav_url(&mut self, http: &impl Http, id: &str) -> Result<Option<String>> {
177 let path = format!("/api/gen/{id}/wav_file/");
178 let body = self.api_get(http, &path).await?;
179 let data: Value = serde_json::from_slice(&body)
180 .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
181 Ok(data
182 .get("wav_file_url")
183 .and_then(Value::as_str)
184 .filter(|url| !url.is_empty())
185 .map(str::to_string))
186 }
187
188 pub async fn aligned_lyrics(&mut self, http: &impl Http, id: &str) -> Result<AlignedLyrics> {
203 let path = format!("/api/gen/{id}/aligned_lyrics/v2/");
204 match self.api_get_retrying(http, &path).await {
205 Ok(body) => Ok(AlignedLyrics::from_bytes(&body)),
206 Err(Error::NotFound(_)) => Ok(AlignedLyrics::default()),
207 Err(err) => Err(err),
208 }
209 }
210
211 pub async fn get_clips_by_ids(&mut self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
224 let mut clips = Vec::new();
225 let mut seen: BTreeSet<&str> = BTreeSet::new();
226 for id in ids {
227 if id.is_empty() || !seen.insert(id) {
228 continue;
229 }
230 let path = format!("/api/clip/{id}");
231 match self.api_get_retrying(http, &path).await {
232 Ok(body) => {
233 if let Some(clip) = parse_clip(&body) {
234 clips.push(clip);
235 }
236 }
237 Err(Error::NotFound(_)) => continue,
238 Err(err) => return Err(err),
239 }
240 }
241 Ok(clips)
242 }
243
244 pub async fn get_clip_parent(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
252 let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
253 match self.api_get_retrying(http, &path).await {
254 Ok(body) => Ok(parse_clip(&body)),
255 Err(Error::NotFound(_)) => Ok(None),
256 Err(err) => Err(err),
257 }
258 }
259
260 pub async fn get_playlists(&mut self, http: &impl Http) -> Result<Vec<Playlist>> {
271 let mut playlists = Vec::new();
272 for page in 1..=MAX_PAGES {
273 let path =
274 format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
275 let body = self.api_get_retrying(http, &path).await?;
276 let page_playlists = parse_playlists(&body)?;
277 if page_playlists.is_empty() {
278 break;
279 }
280 playlists.extend(page_playlists);
281 }
282 Ok(playlists)
283 }
284
285 pub async fn get_playlist_clips(
301 &mut self,
302 http: &impl Http,
303 id: &str,
304 ) -> Result<(Vec<Clip>, bool)> {
305 let path = format!("{PLAYLIST_PATH}{id}/");
306 let body = self.api_get_retrying(http, &path).await?;
307 parse_playlist_clips(&body)
308 }
309
310 pub async fn get_billing_info(&mut self, http: &impl Http) -> Result<BillingInfo> {
312 let body = self.api_get_retrying(http, BILLING_INFO_PATH).await?;
313 parse_billing_info(&body)
314 }
315
316 pub async fn list_stems(
340 &mut self,
341 http: &impl Http,
342 clip_id: &str,
343 ) -> Result<(Vec<Stem>, bool)> {
344 let declared = self.stem_page_count(http, clip_id).await?;
345 if declared == 0 {
348 return Ok((Vec::new(), false));
349 }
350 let pages = declared.min(MAX_PAGES);
351 let mut stems: Vec<Stem> = Vec::new();
352 for page in 0..pages {
353 let path = format!("/api/clip/{clip_id}/stems?page={page}");
356 let body = self.api_get_retrying(http, &path).await?;
360 stems.extend(parse_stems_page(&body));
361 }
362 dedupe_stems(&mut stems);
363 let complete = !stems.is_empty() && declared <= MAX_PAGES;
369 Ok((stems, complete))
370 }
371
372 async fn stem_page_count(&mut self, http: &impl Http, clip_id: &str) -> Result<u32> {
380 let path = format!("/api/clip/{clip_id}/stems/pages");
381 match self.api_get_retrying(http, &path).await {
382 Ok(body) => Ok(parse_stem_page_count(&body)),
383 Err(err) if is_invalid_page_error(&err) => Ok(0),
384 Err(Error::NotFound(_)) => Ok(0),
385 Err(err) => Err(err),
386 }
387 }
388
389 async fn try_get_clip(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
392 let path = format!("/api/clip/{id}");
393 match self.api_get_retrying(http, &path).await {
394 Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
395 Err(Error::NotFound(_)) => Ok(None),
396 Err(err) => Err(err),
397 }
398 }
399
400 async fn find_in_feed(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
402 let (clips, _complete) = self.list_clips(http, false, None).await?;
403 clips
404 .into_iter()
405 .find(|clip| clip.id == id)
406 .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
407 }
408
409 async fn api_get(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
411 self.api_request(http, Method::Get, path, Vec::new()).await
412 }
413
414 async fn api_get_retrying(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
416 self.api_send_retrying(http, Method::Get, path, Vec::new())
417 .await
418 }
419
420 async fn api_send_retrying(
439 &mut self,
440 http: &impl Http,
441 method: Method,
442 path: &str,
443 body: Vec<u8>,
444 ) -> Result<Vec<u8>> {
445 let pace = self.limiter.pace();
446 if !pace.is_zero() {
447 self.clock.sleep(pace).await;
448 }
449 let mut retries = 0;
450 loop {
451 match self.api_request(http, method, path, body.clone()).await {
452 Ok(response) => return Ok(response),
453 Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
454 self.clock.sleep(retry_after_delay(retry_after)).await;
455 retries += 1;
456 }
457 Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
458 self.clock.sleep(backoff_delay(retries, None)).await;
459 retries += 1;
460 }
461 Err(err) => return Err(err),
462 }
463 }
464 }
465
466 async fn api_request(
471 &mut self,
472 http: &impl Http,
473 method: Method,
474 path: &str,
475 body: Vec<u8>,
476 ) -> Result<Vec<u8>> {
477 if method == Method::Post && !post_path_allowed(path) {
483 return Err(Error::Refused(format!(
484 "POST to {path} is not on the allow-list"
485 )));
486 }
487 let url = format!("{SUNO_API_BASE_URL}{path}");
488 let mut auth_refreshed = false;
489 loop {
490 let jwt = self.auth.ensure_jwt(self.clock.now_unix(), http).await?;
491 let mut request = match method {
492 Method::Get => HttpRequest::get(url.clone()),
493 Method::Post => HttpRequest::post(url.clone(), body.clone()),
494 };
495 request
496 .headers
497 .push(("Authorization".to_string(), format!("Bearer {jwt}")));
498 let response = http
499 .send(request)
500 .await
501 .map_err(|err| Error::Connection(err.to_string()))?;
502 match response.status {
503 200..=299 => {
504 self.limiter.on_success();
505 return Ok(response.body);
506 }
507 401 | 403 if !auth_refreshed => {
508 self.auth.invalidate_jwt();
509 auth_refreshed = true;
510 }
511 401 | 403 => {
512 return Err(Error::Auth(format!(
513 "Suno API auth failed with status {}",
514 response.status
515 )));
516 }
517 429 => {
518 self.limiter.on_rate_limit();
519 return Err(Error::RateLimited {
520 retry_after: retry_after(&response),
521 });
522 }
523 404 => {
524 return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
525 }
526 status => {
527 let preview: String = String::from_utf8_lossy(&response.body)
528 .chars()
529 .take(200)
530 .collect();
531 return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
532 }
533 }
534 }
535 }
536}
537
538fn unwrap_clip(value: &Value) -> &Value {
541 value
542 .get("clip")
543 .filter(|clip| clip.is_object())
544 .unwrap_or(value)
545}
546
547fn post_path_allowed(path: &str) -> bool {
557 if path == FEED_V3_PATH {
558 return true;
559 }
560 if let Some(rest) = path.strip_prefix("/api/gen/")
562 && let Some(id) = rest.strip_suffix("/convert_wav/")
563 {
564 return is_single_id_segment(id);
565 }
566 false
567}
568
569fn is_single_id_segment(segment: &str) -> bool {
573 !segment.is_empty()
574 && !segment.contains('/')
575 && !segment.contains('?')
576 && !segment.contains("..")
577}
578
579fn is_invalid_page_error(err: &Error) -> bool {
584 matches!(err, Error::Api(msg) if msg.contains("returned 400") || msg.contains("Invalid page"))
585}
586
587fn parse_stem_page_count(body: &[u8]) -> u32 {
593 serde_json::from_slice::<Value>(body)
594 .ok()
595 .and_then(|data| data.get("pages").and_then(Value::as_u64))
596 .and_then(|pages| u32::try_from(pages).ok())
597 .unwrap_or(0)
598}
599
600fn parse_stems_page(body: &[u8]) -> Vec<Stem> {
610 let Ok(data) = serde_json::from_slice::<Value>(body) else {
611 return Vec::new();
612 };
613 let items = if let Some(array) = data.as_array() {
614 array.as_slice()
615 } else {
616 data.get("stems")
617 .and_then(Value::as_array)
618 .map(Vec::as_slice)
619 .unwrap_or(&[])
620 };
621 items
622 .iter()
623 .map(parse_stem)
624 .filter(|stem| !stem.id.is_empty() && !stem.url.is_empty())
625 .collect()
626}
627
628fn parse_stem(raw: &Value) -> Stem {
631 let clip = Clip::from_json(raw);
632 Stem {
633 id: clip.id.clone(),
634 label: stem_label_from_title(&clip.title),
635 url: clip.mp3_url(),
636 }
637}
638
639fn stem_label_from_title(title: &str) -> String {
644 let trimmed = title.trim_end();
645 let Some(before_close) = trimmed.strip_suffix(')') else {
646 return String::new();
647 };
648 match before_close.rfind('(') {
649 Some(open) => before_close[open + 1..].trim().to_string(),
650 None => String::new(),
651 }
652}
653
654fn dedupe_stems(stems: &mut Vec<Stem>) {
657 let mut seen = BTreeSet::new();
658 stems.retain(|stem| seen.insert(stem.url.clone()));
659}
660
661fn parse_clip(body: &[u8]) -> Option<Clip> {
664 let data: Value = serde_json::from_slice(body).ok()?;
665 let raw = unwrap_clip(&data);
666 let has_id = raw
667 .get("id")
668 .and_then(Value::as_str)
669 .is_some_and(|id| !id.is_empty());
670 has_id.then(|| Clip::from_json(raw))
671}
672
673fn parse_billing_info(body: &[u8]) -> Result<BillingInfo> {
675 let data: Value = serde_json::from_slice(body)
676 .map_err(|err| Error::Api(format!("invalid billing JSON: {err}")))?;
677 let total_credits_left = data
678 .get("total_credits_left")
679 .and_then(json_u64)
680 .ok_or_else(|| Error::Api("invalid billing JSON: missing total_credits_left".into()))?;
681 Ok(BillingInfo { total_credits_left })
682}
683
684fn json_u64(value: &Value) -> Option<u64> {
687 match value {
688 Value::Number(number) => number.as_u64(),
689 Value::String(text) => text.parse().ok(),
690 _ => None,
691 }
692}
693
694fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
701 let mut filters = serde_json::Map::new();
702 filters.insert("trashed".to_string(), Value::String("False".to_string()));
703 if liked {
704 filters.insert("liked".to_string(), Value::String("True".to_string()));
705 }
706 let mut body = serde_json::Map::new();
707 body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
708 body.insert("filters".to_string(), Value::Object(filters));
709 if let Some(cursor) = cursor {
710 body.insert("cursor".to_string(), Value::String(cursor.to_string()));
711 }
712 serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
713}
714
715fn parse_feed_v3(body: &[u8]) -> Result<(Vec<Clip>, Option<bool>, Option<String>)> {
722 let data: Value = serde_json::from_slice(body)
723 .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
724 let Some(object) = data.as_object() else {
725 return Ok((Vec::new(), None, None));
726 };
727 let clips = object
728 .get("clips")
729 .and_then(Value::as_array)
730 .map(|raw| {
731 raw.iter()
732 .map(Clip::from_json)
733 .filter(is_downloadable)
734 .collect()
735 })
736 .unwrap_or_default();
737 let has_more = object.get("has_more").and_then(Value::as_bool);
738 let next_cursor = object
739 .get("next_cursor")
740 .and_then(Value::as_str)
741 .filter(|cursor| !cursor.is_empty())
742 .map(str::to_string);
743 Ok((clips, has_more, next_cursor))
744}
745
746fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
748 let data: Value = serde_json::from_slice(body)
749 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
750 Ok(data
751 .get("playlists")
752 .and_then(Value::as_array)
753 .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
754 .unwrap_or_default())
755}
756
757fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
762 let id = raw
763 .get("id")
764 .and_then(Value::as_str)
765 .filter(|id| !id.is_empty())?
766 .to_string();
767 let name = match raw.get("name") {
768 Some(Value::String(name)) if !name.is_empty() => name.clone(),
769 _ => "Untitled".to_string(),
770 };
771 let num_clips = raw
772 .get("num_total_results")
773 .and_then(Value::as_u64)
774 .unwrap_or(0);
775 Some(Playlist {
776 id,
777 name,
778 num_clips,
779 })
780}
781
782fn parse_playlist_clips(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
799 let data: Value = serde_json::from_slice(body)
800 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
801 let raw = data.get("playlist_clips").and_then(Value::as_array);
802 let raw_len = raw.map(|a| a.len()).unwrap_or(0);
803 let clips: Vec<Clip> = raw
804 .map(|raw| {
805 raw.iter()
806 .map(|entry| Clip::from_json(unwrap_clip(entry)))
807 .filter(|clip| !clip.id.is_empty())
808 .collect()
809 })
810 .unwrap_or_default();
811 let complete = data
817 .get("num_total_results")
818 .and_then(Value::as_u64)
819 .is_some_and(|total| raw_len as u64 == total);
820 Ok((clips, complete))
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826 use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
827 use std::time::Duration;
828
829 fn feed_body() -> String {
830 serde_json::json!({
831 "has_more": false,
832 "clips": [
833 {
834 "id": "a", "title": "Song A", "status": "complete",
835 "audio_url": "https://cdn1.suno.ai/a.mp3",
836 "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
837 },
838 {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
839 {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
840 {
841 "id": "d", "title": "Context", "status": "complete",
842 "metadata": {"type": "rendered_context_window"}
843 }
844 ]
845 })
846 .to_string()
847 }
848
849 #[test]
850 fn parse_feed_v3_filters_and_reads_pagination() {
851 let (clips, has_more, next_cursor) = parse_feed_v3(feed_body().as_bytes()).unwrap();
852 assert_eq!(has_more, Some(false));
853 assert_eq!(next_cursor, None);
854 assert_eq!(clips.len(), 1);
855 assert_eq!(clips[0].id, "a");
856 assert_eq!(clips[0].tags, "rock");
857 assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
858 }
859
860 #[test]
861 fn feed_v3_body_carries_filters_and_optional_cursor() {
862 let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
863 assert_eq!(first["filters"]["trashed"], "False");
864 assert!(first.get("cursor").is_none());
865 assert!(first["filters"].get("liked").is_none());
866
867 let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
868 assert_eq!(liked["filters"]["liked"], "True");
869 assert_eq!(liked["cursor"], "cur42");
870 }
871
872 #[test]
873 fn audiopipe_url_is_rewritten_to_cdn() {
874 let raw =
875 serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
876 assert_eq!(
877 Clip::from_json(&raw).audio_url,
878 "https://cdn1.suno.ai/x.mp3"
879 );
880 }
881
882 #[test]
883 fn list_clips_authenticates_then_reads_the_feed() {
884 let client_body = serde_json::json!({
885 "response": {
886 "last_active_session_id": "s",
887 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
888 }
889 })
890 .to_string();
891 let http = MockHttp::new(vec![
892 Rule::new(
893 "/v1/client/sessions/",
894 200,
895 r#"{"jwt": "a.b.c"}"#.to_string(),
896 ),
897 Rule::new("/v1/client", 200, client_body),
898 Rule::new("/api/feed/v3", 200, feed_body()),
899 ]);
900
901 let mut auth = ClerkAuth::new("eyJtoken");
902 pollster::block_on(auth.authenticate(&http)).unwrap();
903 let mut client = SunoClient::new(auth, RecordingClock::new());
904 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
905 assert_eq!(clips.len(), 1);
906 assert_eq!(clips[0].id, "a");
907 assert!(complete);
908 }
909
910 #[test]
911 fn api_request_uses_clock_now_unix_for_jwt_expiry() {
912 use crate::consts::JWT_REFRESH_BUFFER;
913 use base64::Engine;
914 let exp = 1_000_000i64;
915 let payload =
916 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#));
917 let jwt_str = format!("hdr.{}.sig", payload);
918 let token_body = format!(r#"{{"jwt": "{jwt_str}"}}"#);
919 let client_body = serde_json::json!({
920 "response": {
921 "last_active_session_id": "s",
922 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
923 }
924 })
925 .to_string();
926
927 let make_http = || {
928 ScriptedHttp::new()
929 .route("/v1/client/sessions/", Reply::json(&token_body))
930 .route("/v1/client", Reply::json(&client_body))
931 .route("/api/feed/v3", Reply::json(&feed_body()))
932 };
933
934 let http = make_http();
936 let mut auth = ClerkAuth::new("eyJtoken");
937 pollster::block_on(auth.authenticate(&http)).unwrap();
938 let mut client = SunoClient::new(auth, RecordingClock::at(exp - JWT_REFRESH_BUFFER));
939 let (clips, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
940 assert_eq!(clips.len(), 1);
941 assert_eq!(http.count("/v1/client/sessions/"), 2);
943
944 let http2 = make_http();
946 let mut auth2 = ClerkAuth::new("eyJtoken");
947 pollster::block_on(auth2.authenticate(&http2)).unwrap();
948 let mut client2 = SunoClient::new(auth2, RecordingClock::at(exp - JWT_REFRESH_BUFFER - 1));
949 let (clips2, _) = pollster::block_on(client2.list_clips(&http2, false, None)).unwrap();
950 assert_eq!(clips2.len(), 1);
951 assert_eq!(http2.count("/v1/client/sessions/"), 1);
953 }
954
955 #[test]
956 fn list_clips_reports_incomplete_when_paging_is_capped() {
957 let mut rules = auth_rules();
958 rules.push(Rule::new(
959 "/api/feed/v3",
960 200,
961 serde_json::json!({
962 "has_more": true,
963 "next_cursor": "cur1",
964 "clips": [{
965 "id": "a", "title": "Song A", "status": "complete",
966 "audio_url": "https://cdn1.suno.ai/a.mp3",
967 "metadata": {"type": "gen"}
968 }]
969 })
970 .to_string(),
971 ));
972 let http = MockHttp::new(rules);
973 let mut client = authed_client(&http);
974
975 let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
976 assert!(!complete);
977 }
978
979 fn auth_rules() -> Vec<Rule> {
980 let client_body = serde_json::json!({
981 "response": {
982 "last_active_session_id": "s",
983 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
984 }
985 })
986 .to_string();
987 vec![
988 Rule::new(
989 "/v1/client/sessions/",
990 200,
991 r#"{"jwt": "a.b.c"}"#.to_string(),
992 ),
993 Rule::new("/v1/client", 200, client_body),
994 ]
995 }
996
997 fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
998 let mut auth = ClerkAuth::new("eyJtoken");
999 pollster::block_on(auth.authenticate(http)).unwrap();
1000 SunoClient::new(auth, RecordingClock::new())
1001 }
1002
1003 #[test]
1004 fn get_billing_info_reads_remaining_credits() {
1005 let mut rules = auth_rules();
1006 rules.push(Rule::new(
1007 BILLING_INFO_PATH,
1008 200,
1009 r#"{"total_credits_left":500,"monthly_limit":1000,"monthly_usage":500}"#.to_string(),
1010 ));
1011 let http = MockHttp::new(rules);
1012 let mut client = authed_client(&http);
1013
1014 let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
1015 assert_eq!(billing.total_credits_left, 500);
1016 }
1017
1018 #[test]
1019 fn get_billing_info_rejects_missing_balance() {
1020 let mut rules = auth_rules();
1021 rules.push(Rule::new(
1022 BILLING_INFO_PATH,
1023 200,
1024 r#"{"monthly_usage":12}"#.to_string(),
1025 ));
1026 let http = MockHttp::new(rules);
1027 let mut client = authed_client(&http);
1028
1029 let err = pollster::block_on(client.get_billing_info(&http)).unwrap_err();
1030 assert!(err.to_string().contains("total_credits_left"));
1031 }
1032
1033 #[test]
1034 fn aligned_lyrics_reads_words_and_lines() {
1035 let mut rules = auth_rules();
1036 let body = serde_json::json!({
1037 "aligned_words": [
1038 {"word": "hi", "success": true, "start_s": 0.5, "end_s": 0.9, "p_align": 0.99}
1039 ],
1040 "aligned_lyrics": [
1041 {"text": "hi", "start_s": 0.5, "end_s": 0.9, "section": "Verse 1",
1042 "words": [{"text": "hi", "start_s": 0.5, "end_s": 0.9}]}
1043 ],
1044 "hoot_cer": 0.2, "is_streamed": false
1045 })
1046 .to_string();
1047 rules.push(Rule::new("/aligned_lyrics/v2/", 200, body));
1048 let http = MockHttp::new(rules);
1049 let mut client = authed_client(&http);
1050
1051 let aligned = pollster::block_on(client.aligned_lyrics(&http, "clip-1")).unwrap();
1052 assert_eq!(aligned.words.len(), 1);
1053 assert_eq!(aligned.lines.len(), 1);
1054 assert_eq!(aligned.lines[0].section, "Verse 1");
1055 assert!(!aligned.is_empty());
1056 }
1057
1058 #[test]
1059 fn aligned_lyrics_empty_arrays_map_to_empty() {
1060 let mut rules = auth_rules();
1061 rules.push(Rule::new(
1062 "/aligned_lyrics/v2/",
1063 200,
1064 r#"{"aligned_words":[],"aligned_lyrics":[],"hoot_cer":1.0}"#.to_string(),
1065 ));
1066 let http = MockHttp::new(rules);
1067 let mut client = authed_client(&http);
1068
1069 let aligned = pollster::block_on(client.aligned_lyrics(&http, "instr")).unwrap();
1070 assert!(aligned.is_empty());
1071 }
1072
1073 #[test]
1074 fn aligned_lyrics_maps_404_to_empty() {
1075 let mut rules = auth_rules();
1076 rules.push(Rule::new(
1077 "/aligned_lyrics/v2/",
1078 404,
1079 "not found".to_string(),
1080 ));
1081 let http = MockHttp::new(rules);
1082 let mut client = authed_client(&http);
1083
1084 let aligned = pollster::block_on(client.aligned_lyrics(&http, "missing")).unwrap();
1085 assert!(aligned.is_empty());
1086 }
1087
1088 fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
1089 let mut auth = ClerkAuth::new("eyJtoken");
1090 pollster::block_on(auth.authenticate(http)).unwrap();
1091 SunoClient::new(auth, clock)
1092 }
1093
1094 fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
1095 let mut page = serde_json::json!({
1096 "has_more": next_cursor.is_some(),
1097 "clips": [{
1098 "id": id, "title": "Song", "status": "complete",
1099 "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
1100 "metadata": {"type": "gen"}
1101 }]
1102 });
1103 if let Some(cursor) = next_cursor {
1104 page["next_cursor"] = serde_json::json!(cursor);
1105 }
1106 page.to_string()
1107 }
1108
1109 #[test]
1110 fn list_clips_retries_a_rate_limited_page() {
1111 let http = ScriptedHttp::new().with_auth().route_seq(
1112 "/api/feed/v3",
1113 vec![Reply::status(429), Reply::json(&feed_body())],
1114 );
1115 let clock = RecordingClock::new();
1116 let mut client = scripted_client(&http, clock.clone());
1117
1118 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1119 assert_eq!(clips.len(), 1);
1120 assert!(complete);
1121 assert_eq!(http.count("/api/feed/v3"), 2);
1123 assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
1124 }
1125
1126 #[test]
1127 fn list_clips_honours_retry_after_on_a_throttled_page() {
1128 let http = ScriptedHttp::new().with_auth().route_seq(
1129 "/api/feed/v3",
1130 vec![
1131 Reply::status(429).with_retry_after(7),
1132 Reply::json(&feed_body()),
1133 ],
1134 );
1135 let clock = RecordingClock::new();
1136 let mut client = scripted_client(&http, clock.clone());
1137
1138 let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1139 assert_eq!(clips.len(), 1);
1140 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
1142 }
1143
1144 #[test]
1145 fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
1146 let http = ScriptedHttp::new().with_auth().route_seq(
1148 "/api/feed/v3",
1149 vec![
1150 Reply::json(&one_clip_page("a", Some("cur1"))),
1151 Reply::status(429),
1152 Reply::json(&one_clip_page("b", None)),
1153 ],
1154 );
1155 let clock = RecordingClock::new();
1156 let mut client = scripted_client(&http, clock.clone());
1157
1158 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1159 assert!(complete);
1160 assert_eq!(clips.len(), 2);
1161 let bodies = http.bodies();
1162 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
1163 assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
1164 let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
1167 let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
1168 assert_eq!(retried["cursor"], "cur1");
1169 assert_eq!(after_retry["cursor"], "cur1");
1170 }
1171
1172 #[test]
1173 fn list_clips_threads_the_cursor_across_pages() {
1174 let http = ScriptedHttp::new().with_auth().route_seq(
1175 "/api/feed/v3",
1176 vec![
1177 Reply::json(&one_clip_page("a", Some("cur1"))),
1178 Reply::json(&one_clip_page("b", None)),
1179 ],
1180 );
1181 let clock = RecordingClock::new();
1182 let mut client = scripted_client(&http, clock.clone());
1183
1184 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1185 assert!(complete);
1186 assert_eq!(clips.len(), 2);
1187 let bodies = http.bodies();
1188 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
1189 assert_eq!(feed_bodies.len(), 2);
1190 let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
1191 let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
1192 assert!(page1.get("cursor").is_none());
1194 assert_eq!(page2["cursor"], "cur1");
1195 }
1196
1197 #[test]
1198 fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
1199 let page = serde_json::json!({
1202 "has_more": true,
1203 "clips": [{
1204 "id": "a", "title": "Song", "status": "complete",
1205 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
1206 }]
1207 })
1208 .to_string();
1209 let http = ScriptedHttp::new()
1210 .with_auth()
1211 .route("/api/feed/v3", Reply::json(&page));
1212 let clock = RecordingClock::new();
1213 let mut client = scripted_client(&http, clock.clone());
1214
1215 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1216 assert!(!complete);
1217 assert_eq!(clips.len(), 1);
1218 assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
1219 }
1220
1221 #[test]
1222 fn list_clips_is_incomplete_when_has_more_is_missing() {
1223 let page = serde_json::json!({
1225 "clips": [{
1226 "id": "a", "title": "Song", "status": "complete",
1227 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
1228 }]
1229 })
1230 .to_string();
1231 let http = ScriptedHttp::new()
1232 .with_auth()
1233 .route("/api/feed/v3", Reply::json(&page));
1234 let clock = RecordingClock::new();
1235 let mut client = scripted_client(&http, clock.clone());
1236
1237 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1238 assert!(!complete);
1239 assert_eq!(clips.len(), 1);
1240 assert_eq!(http.count("/api/feed/v3"), 1);
1241 }
1242
1243 #[test]
1244 fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
1245 let http = ScriptedHttp::new().with_auth().route_seq(
1246 "/api/feed/v3",
1247 vec![
1248 Reply::json(&one_clip_page("a", Some("cur1"))),
1249 Reply::status(500),
1250 ],
1251 );
1252 let clock = RecordingClock::new();
1253 let mut client = scripted_client(&http, clock.clone());
1254
1255 let result = pollster::block_on(client.list_clips(&http, false, None));
1256 assert!(matches!(result, Err(Error::Api(_))));
1257 }
1258
1259 #[test]
1260 fn list_clips_is_complete_on_an_empty_drained_feed() {
1261 let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
1264 let http = ScriptedHttp::new()
1265 .with_auth()
1266 .route("/api/feed/v3", Reply::json(&page));
1267 let clock = RecordingClock::new();
1268 let mut client = scripted_client(&http, clock.clone());
1269
1270 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1271 assert!(complete);
1272 assert!(clips.is_empty());
1273 }
1274
1275 #[test]
1276 fn list_clips_liked_scope_sends_the_liked_filter() {
1277 let http = ScriptedHttp::new()
1278 .with_auth()
1279 .route("/api/feed/v3", Reply::json(&feed_body()));
1280 let clock = RecordingClock::new();
1281 let mut client = scripted_client(&http, clock.clone());
1282
1283 let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
1284 let bodies = http.bodies();
1285 let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
1286 let value: Value = serde_json::from_str(feed_body).unwrap();
1287 assert_eq!(value["filters"]["liked"], "True");
1288 assert_eq!(value["filters"]["trashed"], "False");
1289 }
1290
1291 #[test]
1292 fn list_clips_does_not_pace_an_unthrottled_walk() {
1293 let http = ScriptedHttp::new().with_auth().route_seq(
1294 "/api/feed/v3",
1295 vec![
1296 Reply::json(&one_clip_page("a", Some("cur1"))),
1297 Reply::json(&one_clip_page("e", None)),
1298 ],
1299 );
1300 let clock = RecordingClock::new();
1301 let mut client = scripted_client(&http, clock.clone());
1302
1303 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1304 assert!(complete);
1305 assert_eq!(clips.len(), 2);
1306 assert_eq!(http.count("/api/feed/v3"), 2);
1307 assert!(clock.sleeps().is_empty());
1309 }
1310
1311 #[test]
1312 fn list_clips_slows_its_pace_after_a_throttled_page() {
1313 let http = ScriptedHttp::new().with_auth().route_seq(
1314 "/api/feed/v3",
1315 vec![
1316 Reply::status(429),
1317 Reply::json(&one_clip_page("a", Some("cur1"))),
1318 Reply::json(&one_clip_page("e", None)),
1319 ],
1320 );
1321 let clock = RecordingClock::new();
1322 let mut client = scripted_client(&http, clock.clone());
1323
1324 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1325 assert!(complete);
1326 assert_eq!(clips.len(), 2);
1327 assert_eq!(
1330 clock.sleeps(),
1331 vec![Duration::from_secs(5), Duration::from_secs(1)]
1332 );
1333 }
1334
1335 #[test]
1336 fn list_clips_gives_up_after_max_retries() {
1337 let http = ScriptedHttp::new()
1338 .with_auth()
1339 .route("/api/feed/v3", Reply::status(429));
1340 let clock = RecordingClock::new();
1341 let mut client = scripted_client(&http, clock.clone());
1342
1343 let result = pollster::block_on(client.list_clips(&http, false, None));
1344 assert!(matches!(result, Err(Error::RateLimited { .. })));
1345 let budget = crate::consts::API_MAX_RETRIES as usize;
1346 assert_eq!(clock.sleeps().len(), budget);
1347 assert_eq!(http.count("/api/feed/v3"), budget + 1);
1348 }
1349
1350 #[test]
1351 fn parse_clip_accepts_bare_and_wrapped_shapes() {
1352 let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
1353 assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
1354
1355 let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
1356 assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
1357
1358 let missing = serde_json::json!({"detail": "not found"}).to_string();
1359 assert!(parse_clip(missing.as_bytes()).is_none());
1360 }
1361
1362 #[test]
1363 fn get_clip_uses_the_dedicated_endpoint() {
1364 let clip_body = serde_json::json!({
1365 "id": "z", "title": "Zed", "status": "complete",
1366 "audio_url": "https://cdn1.suno.ai/z.mp3",
1367 "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
1368 })
1369 .to_string();
1370 let mut rules = auth_rules();
1371 rules.push(Rule::new("/api/clip/", 200, clip_body));
1372 let http = MockHttp::new(rules);
1373 let mut client = authed_client(&http);
1374
1375 let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
1376 assert_eq!(clip.id, "z");
1377 assert_eq!(clip.title, "Zed");
1378 assert_eq!(clip.tags, "jazz");
1379 }
1380
1381 #[test]
1382 fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
1383 let mut rules = auth_rules();
1384 rules.push(Rule::new(
1385 "/api/clip/",
1386 404,
1387 r#"{"detail": "not found"}"#.to_string(),
1388 ));
1389 rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
1390 let http = MockHttp::new(rules);
1391 let mut client = authed_client(&http);
1392
1393 let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
1394 assert_eq!(clip.id, "a");
1395 assert_eq!(clip.tags, "rock");
1396 }
1397
1398 #[test]
1399 fn request_wav_accepts_a_2xx_status() {
1400 let mut rules = auth_rules();
1401 rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
1402 let http = MockHttp::new(rules);
1403 let mut client = authed_client(&http);
1404
1405 assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
1406 }
1407
1408 #[test]
1409 fn wav_url_reads_the_ready_url() {
1410 let mut rules = auth_rules();
1411 rules.push(Rule::new(
1412 "/wav_file/",
1413 200,
1414 r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
1415 ));
1416 let http = MockHttp::new(rules);
1417 let mut client = authed_client(&http);
1418
1419 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1420 assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
1421 }
1422
1423 #[test]
1424 fn wav_url_is_none_until_the_render_is_ready() {
1425 let mut rules = auth_rules();
1426 rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
1427 let http = MockHttp::new(rules);
1428 let mut client = authed_client(&http);
1429
1430 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1431 assert_eq!(url, None);
1432 }
1433
1434 #[test]
1435 fn get_clips_by_ids_fetches_each_id_and_keeps_artefacts() {
1436 let p1 = serde_json::json!({
1440 "id": "p1", "title": "Infill Ancestor", "status": "complete",
1441 "metadata": {"type": "gen", "task": "infill"}
1442 })
1443 .to_string();
1444 let p2 = serde_json::json!({
1445 "id": "p2", "title": "Uploaded Root", "status": "complete",
1446 "metadata": {"type": "upload"}
1447 })
1448 .to_string();
1449 let mut rules = auth_rules();
1450 rules.push(Rule::new("/api/clip/p1", 200, p1));
1451 rules.push(Rule::new("/api/clip/p2", 200, p2));
1452 let http = MockHttp::new(rules);
1453 let mut client = authed_client(&http);
1454
1455 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"])).unwrap();
1456 assert_eq!(
1457 clips.len(),
1458 2,
1459 "infill and upload ancestors must not be filtered"
1460 );
1461 assert_eq!(clips[0].id, "p1");
1462 assert_eq!(clips[1].id, "p2");
1463 }
1464
1465 #[test]
1466 fn get_clips_by_ids_returns_a_trashed_clip() {
1467 let trashed = serde_json::json!({
1470 "id": "t1", "title": "Trashed Ancestor", "status": "complete",
1471 "is_trashed": true, "metadata": {"type": "gen"}
1472 })
1473 .to_string();
1474 let mut rules = auth_rules();
1475 rules.push(Rule::new("/api/clip/t1", 200, trashed));
1476 let http = MockHttp::new(rules);
1477 let mut client = authed_client(&http);
1478
1479 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"])).unwrap();
1480 assert_eq!(clips.len(), 1);
1481 assert_eq!(clips[0].id, "t1");
1482 assert!(clips[0].is_trashed);
1483 }
1484
1485 #[test]
1486 fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
1487 let only = serde_json::json!({
1488 "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
1489 })
1490 .to_string();
1491 let http = ScriptedHttp::new()
1492 .with_auth()
1493 .route("/api/clip/gone", Reply::status(404))
1494 .route("/api/clip/only", Reply::json(&only));
1495 let mut client = scripted_client(&http, RecordingClock::new());
1496
1497 let clips =
1498 pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"])).unwrap();
1499 assert_eq!(clips.len(), 1, "the 404 id is skipped");
1500 assert_eq!(clips[0].id, "only");
1501 assert_eq!(http.count("/api/clip/only"), 1);
1503 assert_eq!(http.count("/api/clip/gone"), 1);
1504 }
1505
1506 #[test]
1507 fn get_clip_parent_reads_the_parent_clip() {
1508 let parent = serde_json::json!({
1509 "id": "par", "title": "Ancestor", "status": "complete",
1510 "metadata": {"type": "gen"}
1511 })
1512 .to_string();
1513 let mut rules = auth_rules();
1514 rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
1515 let http = MockHttp::new(rules);
1516 let mut client = authed_client(&http);
1517
1518 let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
1519 assert_eq!(clip.unwrap().id, "par");
1520 }
1521
1522 #[test]
1523 fn get_clip_parent_is_none_for_a_root() {
1524 let mut rules = auth_rules();
1525 rules.push(Rule::new(
1526 "/api/clips/parent",
1527 404,
1528 r#"{"detail": "no parent"}"#.to_string(),
1529 ));
1530 let http = MockHttp::new(rules);
1531 let mut client = authed_client(&http);
1532
1533 let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
1534 assert!(clip.is_none());
1535 }
1536
1537 #[test]
1538 fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
1539 for status in [500u16, 503] {
1543 let mut rules = auth_rules();
1544 rules.push(Rule::new(
1545 "/api/clips/parent",
1546 status,
1547 r#"{"detail": "server error"}"#.to_string(),
1548 ));
1549 let http = MockHttp::new(rules);
1550 let mut client = authed_client(&http);
1551
1552 let result = pollster::block_on(client.get_clip_parent(&http, "child"));
1553 assert!(
1554 matches!(result, Err(Error::Api(_))),
1555 "status {status} must propagate as an error, not Ok(None)"
1556 );
1557 }
1558 }
1559
1560 #[test]
1561 fn get_playlists_maps_entries_and_skips_missing_ids() {
1562 let page1 = serde_json::json!({
1563 "playlists": [
1564 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
1565 {"id": "", "name": "No Id", "num_total_results": 3},
1566 {"name": "Also No Id"}
1567 ]
1568 })
1569 .to_string();
1570 let mut rules = auth_rules();
1571 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1573 rules.push(Rule::new(
1574 "/api/playlist/me?page=2",
1575 200,
1576 r#"{"playlists": []}"#.to_string(),
1577 ));
1578 let http = MockHttp::new(rules);
1579 let mut client = authed_client(&http);
1580
1581 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1582 assert_eq!(playlists.len(), 1, "entries without an id are dropped");
1583 assert_eq!(
1584 playlists[0],
1585 Playlist {
1586 id: "pl1".to_owned(),
1587 name: "Road Trip".to_owned(),
1588 num_clips: 12,
1589 }
1590 );
1591 }
1592
1593 #[test]
1594 fn get_playlists_defaults_a_missing_name_to_untitled() {
1595 let page1 = serde_json::json!({
1596 "playlists": [{"id": "pl9", "num_total_results": 1}]
1597 })
1598 .to_string();
1599 let mut rules = auth_rules();
1600 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1601 rules.push(Rule::new(
1602 "/api/playlist/me?page=2",
1603 200,
1604 r#"{"playlists": []}"#.to_string(),
1605 ));
1606 let http = MockHttp::new(rules);
1607 let mut client = authed_client(&http);
1608
1609 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1610 assert_eq!(playlists[0].name, "Untitled");
1611 }
1612
1613 #[test]
1614 fn get_playlist_clips_preserves_order_and_unwraps_clip() {
1615 let body = serde_json::json!({
1618 "num_total_results": 2,
1619 "playlist_clips": [
1620 {"clip": {
1621 "id": "second", "title": "Second", "status": "complete",
1622 "metadata": {"duration": 60.0, "type": "gen"}
1623 }},
1624 {"clip": {
1625 "id": "first", "title": "First", "status": "complete",
1626 "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
1627 }}
1628 ]
1629 })
1630 .to_string();
1631 let mut rules = auth_rules();
1632 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1633 let http = MockHttp::new(rules);
1634 let mut client = authed_client(&http);
1635
1636 let (clips, complete) =
1637 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1638 assert_eq!(clips.len(), 2, "an infill member is not filtered out");
1639 assert_eq!(clips[0].id, "second");
1640 assert_eq!(clips[1].id, "first");
1641 assert!(
1642 complete,
1643 "returned == num_total_results is fully enumerated"
1644 );
1645 }
1646
1647 #[test]
1648 fn get_playlist_clips_short_page_is_not_complete() {
1649 let body = serde_json::json!({
1651 "num_total_results": 5,
1652 "playlist_clips": [
1653 {"clip": {
1654 "id": "only", "title": "Only", "status": "complete",
1655 "metadata": {"duration": 60.0, "type": "gen"}
1656 }}
1657 ]
1658 })
1659 .to_string();
1660 let mut rules = auth_rules();
1661 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1662 let http = MockHttp::new(rules);
1663 let mut client = authed_client(&http);
1664
1665 let (clips, complete) =
1666 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1667 assert_eq!(clips.len(), 1);
1668 assert!(!complete, "a short page is not fully enumerated");
1669 }
1670
1671 #[test]
1672 fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
1673 let mut rules = auth_rules();
1674 rules.push(Rule::new(
1675 "/api/playlist/empty/",
1676 200,
1677 r#"{"num_total_results": 0, "playlist_clips": []}"#.to_string(),
1678 ));
1679 let http = MockHttp::new(rules);
1680 let mut client = authed_client(&http);
1681
1682 let (clips, complete) =
1683 pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
1684 assert!(clips.is_empty());
1685 assert!(
1686 complete,
1687 "an empty playlist reporting zero total is complete"
1688 );
1689 }
1690
1691 #[test]
1692 fn get_playlist_clips_missing_total_is_not_complete() {
1693 let mut rules = auth_rules();
1697 rules.push(Rule::new(
1698 "/api/playlist/pl1/",
1699 200,
1700 r#"{"playlist_clips": []}"#.to_string(),
1701 ));
1702 let http = MockHttp::new(rules);
1703 let mut client = authed_client(&http);
1704
1705 let (clips, complete) =
1706 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1707 assert!(clips.is_empty());
1708 assert!(!complete, "a missing total is never fully enumerated");
1709 }
1710
1711 fn stem_page(stems: &[(&str, &str, &str)]) -> String {
1714 let entries: Vec<Value> = stems
1715 .iter()
1716 .map(|(id, label, url)| {
1717 serde_json::json!({
1718 "id": id,
1719 "title": format!("My Song ({label})"),
1720 "status": "complete",
1721 "audio_url": url,
1722 })
1723 })
1724 .collect();
1725 serde_json::json!({ "stems": entries }).to_string()
1726 }
1727
1728 fn stem_pages(pages: u32) -> String {
1730 serde_json::json!({ "pages": pages }).to_string()
1731 }
1732
1733 #[test]
1734 fn list_stems_drains_all_declared_pages_and_is_authoritative() {
1735 let http = ScriptedHttp::new()
1738 .with_auth()
1739 .route("stems/pages", Reply::json(&stem_pages(2)))
1740 .route(
1741 "stems?page=0",
1742 Reply::json(&stem_page(&[
1743 ("s1", "Vocals", "https://cdn1.suno.ai/s1.mp3"),
1744 ("s2", "Drums", "https://cdn1.suno.ai/s2.mp3"),
1745 ])),
1746 )
1747 .route(
1748 "stems?page=1",
1749 Reply::json(&stem_page(&[("s3", "Bass", "https://cdn1.suno.ai/s3.mp3")])),
1750 );
1751 let mut client = scripted_client(&http, RecordingClock::new());
1752
1753 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
1754 assert_eq!(stems.len(), 3);
1755 assert_eq!(stems[0].id, "s1");
1756 assert_eq!(stems[0].label, "Vocals");
1757 assert_eq!(stems[0].url, "https://cdn1.suno.ai/s1.mp3");
1758 assert_eq!(stems[2].label, "Bass");
1759 assert!(
1760 complete,
1761 "a fully drained listing that returned stems is authoritative"
1762 );
1763 }
1764
1765 #[test]
1766 fn list_stems_zero_pages_is_indeterminate_never_empty() {
1767 let http = ScriptedHttp::new()
1770 .with_auth()
1771 .route("stems/pages", Reply::json(&stem_pages(0)));
1772 let mut client = scripted_client(&http, RecordingClock::new());
1773
1774 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
1775 assert!(stems.is_empty());
1776 assert!(
1777 !complete,
1778 "an empty listing is indeterminate, so existing stems are kept"
1779 );
1780 }
1781
1782 #[test]
1783 fn list_stems_missing_page_count_is_indeterminate() {
1784 for status in [400u16, 404] {
1787 let http = ScriptedHttp::new()
1788 .with_auth()
1789 .route("stems/pages", Reply::status(status));
1790 let mut client = scripted_client(&http, RecordingClock::new());
1791 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
1792 assert!(stems.is_empty(), "status {status}");
1793 assert!(!complete, "status {status} is indeterminate, not empty");
1794 }
1795 }
1796
1797 #[test]
1798 fn list_stems_page_error_mid_enumeration_propagates() {
1799 let http = ScriptedHttp::new()
1803 .with_auth()
1804 .route("stems/pages", Reply::json(&stem_pages(2)))
1805 .route(
1806 "stems?page=0",
1807 Reply::json(&stem_page(&[(
1808 "s1",
1809 "Vocals",
1810 "https://cdn1.suno.ai/s1.mp3",
1811 )])),
1812 )
1813 .route("stems?page=1", Reply::status(500));
1814 let mut client = scripted_client(&http, RecordingClock::new());
1815
1816 let result = pollster::block_on(client.list_stems(&http, "clip1"));
1817 assert!(result.is_err(), "a 5xx page is not a clean drain");
1818 }
1819
1820 #[test]
1821 fn list_stems_over_max_pages_is_truncated_never_authoritative() {
1822 let http = ScriptedHttp::new()
1827 .with_auth()
1828 .route("stems/pages", Reply::json(&stem_pages(MAX_PAGES + 1)))
1829 .route(
1830 "stems?page=",
1831 Reply::json(&stem_page(&[(
1832 "s1",
1833 "Vocals",
1834 "https://cdn1.suno.ai/s1.mp3",
1835 )])),
1836 );
1837 let mut client = scripted_client(&http, RecordingClock::new());
1838
1839 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
1840 assert!(!stems.is_empty(), "the fetched pages still yield stems");
1841 assert!(
1842 !complete,
1843 "a listing declaring more than MAX_PAGES is truncated, never authoritative"
1844 );
1845 }
1846
1847 #[test]
1848 fn parse_stems_page_maps_full_clips_and_skips_idless() {
1849 let page = stem_page(&[("x", "Backing Vocals", "https://cdn1.suno.ai/x.mp3")]);
1852 let stems = parse_stems_page(page.as_bytes());
1853 assert_eq!(stems.len(), 1);
1854 assert_eq!(stems[0].id, "x");
1855 assert_eq!(stems[0].label, "Backing Vocals");
1856 assert_eq!(stems[0].url, "https://cdn1.suno.ai/x.mp3");
1857 let no_id = br#"{"stems": [{"title": "Ghost (Vocals)", "audio_url": "https://cdn1.suno.ai/g.mp3"}]}"#;
1859 assert!(parse_stems_page(no_id).is_empty());
1860 let no_url = br#"{"stems": [{"id": "y", "title": "Song (Bass)"}]}"#;
1863 let recovered = parse_stems_page(no_url);
1864 assert_eq!(recovered.len(), 1);
1865 assert_eq!(recovered[0].url, "https://cdn1.suno.ai/y.mp3");
1866 assert!(parse_stems_page(b"not json").is_empty());
1868 }
1869
1870 #[test]
1871 fn parse_stem_page_count_reads_pages_field() {
1872 assert_eq!(parse_stem_page_count(br#"{"pages": 12}"#), 12);
1873 assert_eq!(parse_stem_page_count(br#"{"pages": 0}"#), 0);
1874 assert_eq!(parse_stem_page_count(br#"{}"#), 0);
1876 assert_eq!(parse_stem_page_count(br#"{"pages": -1}"#), 0);
1877 assert_eq!(parse_stem_page_count(b"not json"), 0);
1878 }
1879
1880 #[test]
1881 fn stem_label_from_title_extracts_trailing_parenthetical() {
1882 assert_eq!(stem_label_from_title("My Song (Vocals)"), "Vocals");
1883 assert_eq!(
1884 stem_label_from_title("A (b) Song (Backing Vocals)"),
1885 "Backing Vocals"
1886 );
1887 assert_eq!(stem_label_from_title("My Song (Drums) "), "Drums");
1888 assert_eq!(stem_label_from_title("My Song"), "");
1890 assert_eq!(stem_label_from_title(""), "");
1891 }
1892
1893 #[test]
1894 fn post_allow_list_permits_only_feed_and_wav_render() {
1895 assert!(post_path_allowed(FEED_V3_PATH));
1896 assert!(post_path_allowed("/api/gen/abc123/convert_wav/"));
1897 assert!(!post_path_allowed("/api/gen/abc123/stem_task"));
1899 assert!(!post_path_allowed("/api/gen/abc123/separate"));
1900 assert!(!post_path_allowed("/api/gen/a/../evil/convert_wav/"));
1902 assert!(!post_path_allowed("/api/gen/a/b/convert_wav/"));
1903 assert!(!post_path_allowed("/api/clip/x/stems/pages"));
1905 assert!(!post_path_allowed("/api/clip/x/stems?page=0"));
1906 }
1907
1908 #[test]
1909 fn api_request_refuses_a_post_off_the_allow_list() {
1910 let http = MockHttp::new(auth_rules());
1913 let mut client = authed_client(&http);
1914 let err = pollster::block_on(client.api_request(
1915 &http,
1916 Method::Post,
1917 "/api/gen/x/stem_task",
1918 b"{}".to_vec(),
1919 ))
1920 .unwrap_err();
1921 assert!(matches!(err, Error::Refused(_)));
1922 }
1923}