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 400 => {
524 let preview: String = String::from_utf8_lossy(&response.body)
525 .chars()
526 .take(200)
527 .collect();
528 return Err(Error::BadRequest(format!(
529 "Suno API returned 400: {preview}"
530 )));
531 }
532 404 => {
533 return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
534 }
535 status => {
536 let preview: String = String::from_utf8_lossy(&response.body)
537 .chars()
538 .take(200)
539 .collect();
540 return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
541 }
542 }
543 }
544 }
545}
546
547fn unwrap_clip(value: &Value) -> &Value {
550 value
551 .get("clip")
552 .filter(|clip| clip.is_object())
553 .unwrap_or(value)
554}
555
556fn post_path_allowed(path: &str) -> bool {
566 if path == FEED_V3_PATH {
567 return true;
568 }
569 if let Some(rest) = path.strip_prefix("/api/gen/")
571 && let Some(id) = rest.strip_suffix("/convert_wav/")
572 {
573 return is_single_id_segment(id);
574 }
575 false
576}
577
578fn is_single_id_segment(segment: &str) -> bool {
582 !segment.is_empty()
583 && !segment.contains('/')
584 && !segment.contains('?')
585 && !segment.contains("..")
586}
587
588fn is_invalid_page_error(err: &Error) -> bool {
593 matches!(err, Error::BadRequest(_))
594}
595
596fn parse_stem_page_count(body: &[u8]) -> u32 {
602 serde_json::from_slice::<Value>(body)
603 .ok()
604 .and_then(|data| data.get("pages").and_then(Value::as_u64))
605 .and_then(|pages| u32::try_from(pages).ok())
606 .unwrap_or(0)
607}
608
609fn parse_stems_page(body: &[u8]) -> Vec<Stem> {
619 let Ok(data) = serde_json::from_slice::<Value>(body) else {
620 return Vec::new();
621 };
622 let items = if let Some(array) = data.as_array() {
623 array.as_slice()
624 } else {
625 data.get("stems")
626 .and_then(Value::as_array)
627 .map(Vec::as_slice)
628 .unwrap_or(&[])
629 };
630 items
631 .iter()
632 .map(parse_stem)
633 .filter(|stem| !stem.id.is_empty() && !stem.url.is_empty())
634 .collect()
635}
636
637fn parse_stem(raw: &Value) -> Stem {
640 let clip = Clip::from_json(raw);
641 Stem {
642 id: clip.id.clone(),
643 label: stem_label_from_title(&clip.title),
644 url: clip.mp3_url(),
645 }
646}
647
648fn stem_label_from_title(title: &str) -> String {
653 let trimmed = title.trim_end();
654 let Some(before_close) = trimmed.strip_suffix(')') else {
655 return String::new();
656 };
657 match before_close.rfind('(') {
658 Some(open) => before_close[open + 1..].trim().to_string(),
659 None => String::new(),
660 }
661}
662
663fn dedupe_stems(stems: &mut Vec<Stem>) {
666 let mut seen = BTreeSet::new();
667 stems.retain(|stem| seen.insert(stem.url.clone()));
668}
669
670fn parse_clip(body: &[u8]) -> Option<Clip> {
673 let data: Value = serde_json::from_slice(body).ok()?;
674 let raw = unwrap_clip(&data);
675 let has_id = raw
676 .get("id")
677 .and_then(Value::as_str)
678 .is_some_and(|id| !id.is_empty());
679 has_id.then(|| Clip::from_json(raw))
680}
681
682fn parse_billing_info(body: &[u8]) -> Result<BillingInfo> {
684 let data: Value = serde_json::from_slice(body)
685 .map_err(|err| Error::Api(format!("invalid billing JSON: {err}")))?;
686 let total_credits_left = data
687 .get("total_credits_left")
688 .and_then(json_u64)
689 .ok_or_else(|| Error::Api("invalid billing JSON: missing total_credits_left".into()))?;
690 Ok(BillingInfo { total_credits_left })
691}
692
693fn json_u64(value: &Value) -> Option<u64> {
696 match value {
697 Value::Number(number) => number.as_u64(),
698 Value::String(text) => text.parse().ok(),
699 _ => None,
700 }
701}
702
703fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
710 let mut filters = serde_json::Map::new();
711 filters.insert("trashed".to_string(), Value::String("False".to_string()));
712 if liked {
713 filters.insert("liked".to_string(), Value::String("True".to_string()));
714 }
715 let mut body = serde_json::Map::new();
716 body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
717 body.insert("filters".to_string(), Value::Object(filters));
718 if let Some(cursor) = cursor {
719 body.insert("cursor".to_string(), Value::String(cursor.to_string()));
720 }
721 serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
722}
723
724fn parse_feed_v3(body: &[u8]) -> Result<(Vec<Clip>, Option<bool>, Option<String>)> {
731 let data: Value = serde_json::from_slice(body)
732 .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
733 let Some(object) = data.as_object() else {
734 return Ok((Vec::new(), None, None));
735 };
736 let clips = object
737 .get("clips")
738 .and_then(Value::as_array)
739 .map(|raw| {
740 raw.iter()
741 .map(Clip::from_json)
742 .filter(is_downloadable)
743 .collect()
744 })
745 .unwrap_or_default();
746 let has_more = object.get("has_more").and_then(Value::as_bool);
747 let next_cursor = object
748 .get("next_cursor")
749 .and_then(Value::as_str)
750 .filter(|cursor| !cursor.is_empty())
751 .map(str::to_string);
752 Ok((clips, has_more, next_cursor))
753}
754
755fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
757 let data: Value = serde_json::from_slice(body)
758 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
759 Ok(data
760 .get("playlists")
761 .and_then(Value::as_array)
762 .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
763 .unwrap_or_default())
764}
765
766fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
771 let id = raw
772 .get("id")
773 .and_then(Value::as_str)
774 .filter(|id| !id.is_empty())?
775 .to_string();
776 let name = match raw.get("name") {
777 Some(Value::String(name)) if !name.is_empty() => name.clone(),
778 _ => "Untitled".to_string(),
779 };
780 let num_clips = raw
781 .get("num_total_results")
782 .and_then(Value::as_u64)
783 .unwrap_or(0);
784 Some(Playlist {
785 id,
786 name,
787 num_clips,
788 })
789}
790
791fn parse_playlist_clips(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
808 let data: Value = serde_json::from_slice(body)
809 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
810 let raw = data.get("playlist_clips").and_then(Value::as_array);
811 let raw_len = raw.map(|a| a.len()).unwrap_or(0);
812 let clips: Vec<Clip> = raw
813 .map(|raw| {
814 raw.iter()
815 .map(|entry| Clip::from_json(unwrap_clip(entry)))
816 .filter(|clip| !clip.id.is_empty())
817 .collect()
818 })
819 .unwrap_or_default();
820 let complete = data
826 .get("num_total_results")
827 .and_then(Value::as_u64)
828 .is_some_and(|total| raw_len as u64 == total);
829 Ok((clips, complete))
830}
831
832#[cfg(test)]
833mod tests {
834 use super::*;
835 use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
836 use std::time::Duration;
837
838 fn feed_body() -> String {
839 serde_json::json!({
840 "has_more": false,
841 "clips": [
842 {
843 "id": "a", "title": "Song A", "status": "complete",
844 "audio_url": "https://cdn1.suno.ai/a.mp3",
845 "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
846 },
847 {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
848 {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
849 {
850 "id": "d", "title": "Context", "status": "complete",
851 "metadata": {"type": "rendered_context_window"}
852 }
853 ]
854 })
855 .to_string()
856 }
857
858 #[test]
859 fn parse_feed_v3_filters_and_reads_pagination() {
860 let (clips, has_more, next_cursor) = parse_feed_v3(feed_body().as_bytes()).unwrap();
861 assert_eq!(has_more, Some(false));
862 assert_eq!(next_cursor, None);
863 assert_eq!(clips.len(), 1);
864 assert_eq!(clips[0].id, "a");
865 assert_eq!(clips[0].tags, "rock");
866 assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
867 }
868
869 #[test]
870 fn feed_v3_body_carries_filters_and_optional_cursor() {
871 let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
872 assert_eq!(first["filters"]["trashed"], "False");
873 assert!(first.get("cursor").is_none());
874 assert!(first["filters"].get("liked").is_none());
875
876 let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
877 assert_eq!(liked["filters"]["liked"], "True");
878 assert_eq!(liked["cursor"], "cur42");
879 }
880
881 #[test]
882 fn audiopipe_url_is_rewritten_to_cdn() {
883 let raw =
884 serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
885 assert_eq!(
886 Clip::from_json(&raw).audio_url,
887 "https://cdn1.suno.ai/x.mp3"
888 );
889 }
890
891 #[test]
892 fn list_clips_authenticates_then_reads_the_feed() {
893 let client_body = serde_json::json!({
894 "response": {
895 "last_active_session_id": "s",
896 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
897 }
898 })
899 .to_string();
900 let http = MockHttp::new(vec![
901 Rule::new(
902 "/v1/client/sessions/",
903 200,
904 r#"{"jwt": "a.b.c"}"#.to_string(),
905 ),
906 Rule::new("/v1/client", 200, client_body),
907 Rule::new("/api/feed/v3", 200, feed_body()),
908 ]);
909
910 let mut auth = ClerkAuth::new("eyJtoken");
911 pollster::block_on(auth.authenticate(&http)).unwrap();
912 let mut client = SunoClient::new(auth, RecordingClock::new());
913 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
914 assert_eq!(clips.len(), 1);
915 assert_eq!(clips[0].id, "a");
916 assert!(complete);
917 }
918
919 #[test]
920 fn api_request_uses_clock_now_unix_for_jwt_expiry() {
921 use crate::consts::JWT_REFRESH_BUFFER;
922 use base64::Engine;
923 let exp = 1_000_000i64;
924 let payload =
925 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#));
926 let jwt_str = format!("hdr.{}.sig", payload);
927 let token_body = format!(r#"{{"jwt": "{jwt_str}"}}"#);
928 let client_body = serde_json::json!({
929 "response": {
930 "last_active_session_id": "s",
931 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
932 }
933 })
934 .to_string();
935
936 let make_http = || {
937 ScriptedHttp::new()
938 .route("/v1/client/sessions/", Reply::json(&token_body))
939 .route("/v1/client", Reply::json(&client_body))
940 .route("/api/feed/v3", Reply::json(&feed_body()))
941 };
942
943 let http = make_http();
945 let mut auth = ClerkAuth::new("eyJtoken");
946 pollster::block_on(auth.authenticate(&http)).unwrap();
947 let mut client = SunoClient::new(auth, RecordingClock::at(exp - JWT_REFRESH_BUFFER));
948 let (clips, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
949 assert_eq!(clips.len(), 1);
950 assert_eq!(http.count("/v1/client/sessions/"), 2);
952
953 let http2 = make_http();
955 let mut auth2 = ClerkAuth::new("eyJtoken");
956 pollster::block_on(auth2.authenticate(&http2)).unwrap();
957 let mut client2 = SunoClient::new(auth2, RecordingClock::at(exp - JWT_REFRESH_BUFFER - 1));
958 let (clips2, _) = pollster::block_on(client2.list_clips(&http2, false, None)).unwrap();
959 assert_eq!(clips2.len(), 1);
960 assert_eq!(http2.count("/v1/client/sessions/"), 1);
962 }
963
964 #[test]
965 fn list_clips_reports_incomplete_when_paging_is_capped() {
966 let mut rules = auth_rules();
967 rules.push(Rule::new(
968 "/api/feed/v3",
969 200,
970 serde_json::json!({
971 "has_more": true,
972 "next_cursor": "cur1",
973 "clips": [{
974 "id": "a", "title": "Song A", "status": "complete",
975 "audio_url": "https://cdn1.suno.ai/a.mp3",
976 "metadata": {"type": "gen"}
977 }]
978 })
979 .to_string(),
980 ));
981 let http = MockHttp::new(rules);
982 let mut client = authed_client(&http);
983
984 let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
985 assert!(!complete);
986 }
987
988 fn auth_rules() -> Vec<Rule> {
989 let client_body = serde_json::json!({
990 "response": {
991 "last_active_session_id": "s",
992 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
993 }
994 })
995 .to_string();
996 vec![
997 Rule::new(
998 "/v1/client/sessions/",
999 200,
1000 r#"{"jwt": "a.b.c"}"#.to_string(),
1001 ),
1002 Rule::new("/v1/client", 200, client_body),
1003 ]
1004 }
1005
1006 fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
1007 let mut auth = ClerkAuth::new("eyJtoken");
1008 pollster::block_on(auth.authenticate(http)).unwrap();
1009 SunoClient::new(auth, RecordingClock::new())
1010 }
1011
1012 #[test]
1013 fn get_billing_info_reads_remaining_credits() {
1014 let mut rules = auth_rules();
1015 rules.push(Rule::new(
1016 BILLING_INFO_PATH,
1017 200,
1018 r#"{"total_credits_left":500,"monthly_limit":1000,"monthly_usage":500}"#.to_string(),
1019 ));
1020 let http = MockHttp::new(rules);
1021 let mut client = authed_client(&http);
1022
1023 let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
1024 assert_eq!(billing.total_credits_left, 500);
1025 }
1026
1027 #[test]
1028 fn get_billing_info_rejects_missing_balance() {
1029 let mut rules = auth_rules();
1030 rules.push(Rule::new(
1031 BILLING_INFO_PATH,
1032 200,
1033 r#"{"monthly_usage":12}"#.to_string(),
1034 ));
1035 let http = MockHttp::new(rules);
1036 let mut client = authed_client(&http);
1037
1038 let err = pollster::block_on(client.get_billing_info(&http)).unwrap_err();
1039 assert!(err.to_string().contains("total_credits_left"));
1040 }
1041
1042 #[test]
1043 fn aligned_lyrics_reads_words_and_lines() {
1044 let mut rules = auth_rules();
1045 let body = serde_json::json!({
1046 "aligned_words": [
1047 {"word": "hi", "success": true, "start_s": 0.5, "end_s": 0.9, "p_align": 0.99}
1048 ],
1049 "aligned_lyrics": [
1050 {"text": "hi", "start_s": 0.5, "end_s": 0.9, "section": "Verse 1",
1051 "words": [{"text": "hi", "start_s": 0.5, "end_s": 0.9}]}
1052 ],
1053 "hoot_cer": 0.2, "is_streamed": false
1054 })
1055 .to_string();
1056 rules.push(Rule::new("/aligned_lyrics/v2/", 200, body));
1057 let http = MockHttp::new(rules);
1058 let mut client = authed_client(&http);
1059
1060 let aligned = pollster::block_on(client.aligned_lyrics(&http, "clip-1")).unwrap();
1061 assert_eq!(aligned.words.len(), 1);
1062 assert_eq!(aligned.lines.len(), 1);
1063 assert_eq!(aligned.lines[0].section, "Verse 1");
1064 assert!(!aligned.is_empty());
1065 }
1066
1067 #[test]
1068 fn aligned_lyrics_empty_arrays_map_to_empty() {
1069 let mut rules = auth_rules();
1070 rules.push(Rule::new(
1071 "/aligned_lyrics/v2/",
1072 200,
1073 r#"{"aligned_words":[],"aligned_lyrics":[],"hoot_cer":1.0}"#.to_string(),
1074 ));
1075 let http = MockHttp::new(rules);
1076 let mut client = authed_client(&http);
1077
1078 let aligned = pollster::block_on(client.aligned_lyrics(&http, "instr")).unwrap();
1079 assert!(aligned.is_empty());
1080 }
1081
1082 #[test]
1083 fn aligned_lyrics_maps_404_to_empty() {
1084 let mut rules = auth_rules();
1085 rules.push(Rule::new(
1086 "/aligned_lyrics/v2/",
1087 404,
1088 "not found".to_string(),
1089 ));
1090 let http = MockHttp::new(rules);
1091 let mut client = authed_client(&http);
1092
1093 let aligned = pollster::block_on(client.aligned_lyrics(&http, "missing")).unwrap();
1094 assert!(aligned.is_empty());
1095 }
1096
1097 fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
1098 let mut auth = ClerkAuth::new("eyJtoken");
1099 pollster::block_on(auth.authenticate(http)).unwrap();
1100 SunoClient::new(auth, clock)
1101 }
1102
1103 fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
1104 let mut page = serde_json::json!({
1105 "has_more": next_cursor.is_some(),
1106 "clips": [{
1107 "id": id, "title": "Song", "status": "complete",
1108 "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
1109 "metadata": {"type": "gen"}
1110 }]
1111 });
1112 if let Some(cursor) = next_cursor {
1113 page["next_cursor"] = serde_json::json!(cursor);
1114 }
1115 page.to_string()
1116 }
1117
1118 #[test]
1119 fn list_clips_retries_a_rate_limited_page() {
1120 let http = ScriptedHttp::new().with_auth().route_seq(
1121 "/api/feed/v3",
1122 vec![Reply::status(429), Reply::json(&feed_body())],
1123 );
1124 let clock = RecordingClock::new();
1125 let mut client = scripted_client(&http, clock.clone());
1126
1127 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1128 assert_eq!(clips.len(), 1);
1129 assert!(complete);
1130 assert_eq!(http.count("/api/feed/v3"), 2);
1132 assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
1133 }
1134
1135 #[test]
1136 fn list_clips_honours_retry_after_on_a_throttled_page() {
1137 let http = ScriptedHttp::new().with_auth().route_seq(
1138 "/api/feed/v3",
1139 vec![
1140 Reply::status(429).with_retry_after(7),
1141 Reply::json(&feed_body()),
1142 ],
1143 );
1144 let clock = RecordingClock::new();
1145 let mut client = scripted_client(&http, clock.clone());
1146
1147 let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1148 assert_eq!(clips.len(), 1);
1149 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
1151 }
1152
1153 #[test]
1154 fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
1155 let http = ScriptedHttp::new().with_auth().route_seq(
1157 "/api/feed/v3",
1158 vec![
1159 Reply::json(&one_clip_page("a", Some("cur1"))),
1160 Reply::status(429),
1161 Reply::json(&one_clip_page("b", None)),
1162 ],
1163 );
1164 let clock = RecordingClock::new();
1165 let mut client = scripted_client(&http, clock.clone());
1166
1167 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1168 assert!(complete);
1169 assert_eq!(clips.len(), 2);
1170 let bodies = http.bodies();
1171 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
1172 assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
1173 let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
1176 let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
1177 assert_eq!(retried["cursor"], "cur1");
1178 assert_eq!(after_retry["cursor"], "cur1");
1179 }
1180
1181 #[test]
1182 fn list_clips_threads_the_cursor_across_pages() {
1183 let http = ScriptedHttp::new().with_auth().route_seq(
1184 "/api/feed/v3",
1185 vec![
1186 Reply::json(&one_clip_page("a", Some("cur1"))),
1187 Reply::json(&one_clip_page("b", None)),
1188 ],
1189 );
1190 let clock = RecordingClock::new();
1191 let mut client = scripted_client(&http, clock.clone());
1192
1193 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1194 assert!(complete);
1195 assert_eq!(clips.len(), 2);
1196 let bodies = http.bodies();
1197 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
1198 assert_eq!(feed_bodies.len(), 2);
1199 let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
1200 let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
1201 assert!(page1.get("cursor").is_none());
1203 assert_eq!(page2["cursor"], "cur1");
1204 }
1205
1206 #[test]
1207 fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
1208 let page = serde_json::json!({
1211 "has_more": true,
1212 "clips": [{
1213 "id": "a", "title": "Song", "status": "complete",
1214 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
1215 }]
1216 })
1217 .to_string();
1218 let http = ScriptedHttp::new()
1219 .with_auth()
1220 .route("/api/feed/v3", Reply::json(&page));
1221 let clock = RecordingClock::new();
1222 let mut client = scripted_client(&http, clock.clone());
1223
1224 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1225 assert!(!complete);
1226 assert_eq!(clips.len(), 1);
1227 assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
1228 }
1229
1230 #[test]
1231 fn list_clips_is_incomplete_when_has_more_is_missing() {
1232 let page = serde_json::json!({
1234 "clips": [{
1235 "id": "a", "title": "Song", "status": "complete",
1236 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
1237 }]
1238 })
1239 .to_string();
1240 let http = ScriptedHttp::new()
1241 .with_auth()
1242 .route("/api/feed/v3", Reply::json(&page));
1243 let clock = RecordingClock::new();
1244 let mut client = scripted_client(&http, clock.clone());
1245
1246 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1247 assert!(!complete);
1248 assert_eq!(clips.len(), 1);
1249 assert_eq!(http.count("/api/feed/v3"), 1);
1250 }
1251
1252 #[test]
1253 fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
1254 let http = ScriptedHttp::new().with_auth().route_seq(
1255 "/api/feed/v3",
1256 vec![
1257 Reply::json(&one_clip_page("a", Some("cur1"))),
1258 Reply::status(500),
1259 ],
1260 );
1261 let clock = RecordingClock::new();
1262 let mut client = scripted_client(&http, clock.clone());
1263
1264 let result = pollster::block_on(client.list_clips(&http, false, None));
1265 assert!(matches!(result, Err(Error::Api(_))));
1266 }
1267
1268 #[test]
1269 fn list_clips_is_complete_on_an_empty_drained_feed() {
1270 let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
1273 let http = ScriptedHttp::new()
1274 .with_auth()
1275 .route("/api/feed/v3", Reply::json(&page));
1276 let clock = RecordingClock::new();
1277 let mut client = scripted_client(&http, clock.clone());
1278
1279 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1280 assert!(complete);
1281 assert!(clips.is_empty());
1282 }
1283
1284 #[test]
1285 fn list_clips_liked_scope_sends_the_liked_filter() {
1286 let http = ScriptedHttp::new()
1287 .with_auth()
1288 .route("/api/feed/v3", Reply::json(&feed_body()));
1289 let clock = RecordingClock::new();
1290 let mut client = scripted_client(&http, clock.clone());
1291
1292 let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
1293 let bodies = http.bodies();
1294 let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
1295 let value: Value = serde_json::from_str(feed_body).unwrap();
1296 assert_eq!(value["filters"]["liked"], "True");
1297 assert_eq!(value["filters"]["trashed"], "False");
1298 }
1299
1300 #[test]
1301 fn list_clips_does_not_pace_an_unthrottled_walk() {
1302 let http = ScriptedHttp::new().with_auth().route_seq(
1303 "/api/feed/v3",
1304 vec![
1305 Reply::json(&one_clip_page("a", Some("cur1"))),
1306 Reply::json(&one_clip_page("e", None)),
1307 ],
1308 );
1309 let clock = RecordingClock::new();
1310 let mut client = scripted_client(&http, clock.clone());
1311
1312 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1313 assert!(complete);
1314 assert_eq!(clips.len(), 2);
1315 assert_eq!(http.count("/api/feed/v3"), 2);
1316 assert!(clock.sleeps().is_empty());
1318 }
1319
1320 #[test]
1321 fn list_clips_slows_its_pace_after_a_throttled_page() {
1322 let http = ScriptedHttp::new().with_auth().route_seq(
1323 "/api/feed/v3",
1324 vec![
1325 Reply::status(429),
1326 Reply::json(&one_clip_page("a", Some("cur1"))),
1327 Reply::json(&one_clip_page("e", None)),
1328 ],
1329 );
1330 let clock = RecordingClock::new();
1331 let mut client = scripted_client(&http, clock.clone());
1332
1333 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1334 assert!(complete);
1335 assert_eq!(clips.len(), 2);
1336 assert_eq!(
1339 clock.sleeps(),
1340 vec![Duration::from_secs(5), Duration::from_secs(1)]
1341 );
1342 }
1343
1344 #[test]
1345 fn list_clips_gives_up_after_max_retries() {
1346 let http = ScriptedHttp::new()
1347 .with_auth()
1348 .route("/api/feed/v3", Reply::status(429));
1349 let clock = RecordingClock::new();
1350 let mut client = scripted_client(&http, clock.clone());
1351
1352 let result = pollster::block_on(client.list_clips(&http, false, None));
1353 assert!(matches!(result, Err(Error::RateLimited { .. })));
1354 let budget = crate::consts::API_MAX_RETRIES as usize;
1355 assert_eq!(clock.sleeps().len(), budget);
1356 assert_eq!(http.count("/api/feed/v3"), budget + 1);
1357 }
1358
1359 #[test]
1360 fn parse_clip_accepts_bare_and_wrapped_shapes() {
1361 let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
1362 assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
1363
1364 let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
1365 assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
1366
1367 let missing = serde_json::json!({"detail": "not found"}).to_string();
1368 assert!(parse_clip(missing.as_bytes()).is_none());
1369 }
1370
1371 #[test]
1372 fn get_clip_uses_the_dedicated_endpoint() {
1373 let clip_body = serde_json::json!({
1374 "id": "z", "title": "Zed", "status": "complete",
1375 "audio_url": "https://cdn1.suno.ai/z.mp3",
1376 "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
1377 })
1378 .to_string();
1379 let mut rules = auth_rules();
1380 rules.push(Rule::new("/api/clip/", 200, clip_body));
1381 let http = MockHttp::new(rules);
1382 let mut client = authed_client(&http);
1383
1384 let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
1385 assert_eq!(clip.id, "z");
1386 assert_eq!(clip.title, "Zed");
1387 assert_eq!(clip.tags, "jazz");
1388 }
1389
1390 #[test]
1391 fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
1392 let mut rules = auth_rules();
1393 rules.push(Rule::new(
1394 "/api/clip/",
1395 404,
1396 r#"{"detail": "not found"}"#.to_string(),
1397 ));
1398 rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
1399 let http = MockHttp::new(rules);
1400 let mut client = authed_client(&http);
1401
1402 let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
1403 assert_eq!(clip.id, "a");
1404 assert_eq!(clip.tags, "rock");
1405 }
1406
1407 #[test]
1408 fn request_wav_accepts_a_2xx_status() {
1409 let mut rules = auth_rules();
1410 rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
1411 let http = MockHttp::new(rules);
1412 let mut client = authed_client(&http);
1413
1414 assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
1415 }
1416
1417 #[test]
1418 fn wav_url_reads_the_ready_url() {
1419 let mut rules = auth_rules();
1420 rules.push(Rule::new(
1421 "/wav_file/",
1422 200,
1423 r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
1424 ));
1425 let http = MockHttp::new(rules);
1426 let mut client = authed_client(&http);
1427
1428 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1429 assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
1430 }
1431
1432 #[test]
1433 fn wav_url_is_none_until_the_render_is_ready() {
1434 let mut rules = auth_rules();
1435 rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
1436 let http = MockHttp::new(rules);
1437 let mut client = authed_client(&http);
1438
1439 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1440 assert_eq!(url, None);
1441 }
1442
1443 #[test]
1444 fn get_clips_by_ids_fetches_each_id_and_keeps_artefacts() {
1445 let p1 = serde_json::json!({
1449 "id": "p1", "title": "Infill Ancestor", "status": "complete",
1450 "metadata": {"type": "gen", "task": "infill"}
1451 })
1452 .to_string();
1453 let p2 = serde_json::json!({
1454 "id": "p2", "title": "Uploaded Root", "status": "complete",
1455 "metadata": {"type": "upload"}
1456 })
1457 .to_string();
1458 let mut rules = auth_rules();
1459 rules.push(Rule::new("/api/clip/p1", 200, p1));
1460 rules.push(Rule::new("/api/clip/p2", 200, p2));
1461 let http = MockHttp::new(rules);
1462 let mut client = authed_client(&http);
1463
1464 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"])).unwrap();
1465 assert_eq!(
1466 clips.len(),
1467 2,
1468 "infill and upload ancestors must not be filtered"
1469 );
1470 assert_eq!(clips[0].id, "p1");
1471 assert_eq!(clips[1].id, "p2");
1472 }
1473
1474 #[test]
1475 fn get_clips_by_ids_returns_a_trashed_clip() {
1476 let trashed = serde_json::json!({
1479 "id": "t1", "title": "Trashed Ancestor", "status": "complete",
1480 "is_trashed": true, "metadata": {"type": "gen"}
1481 })
1482 .to_string();
1483 let mut rules = auth_rules();
1484 rules.push(Rule::new("/api/clip/t1", 200, trashed));
1485 let http = MockHttp::new(rules);
1486 let mut client = authed_client(&http);
1487
1488 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"])).unwrap();
1489 assert_eq!(clips.len(), 1);
1490 assert_eq!(clips[0].id, "t1");
1491 assert!(clips[0].is_trashed);
1492 }
1493
1494 #[test]
1495 fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
1496 let only = serde_json::json!({
1497 "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
1498 })
1499 .to_string();
1500 let http = ScriptedHttp::new()
1501 .with_auth()
1502 .route("/api/clip/gone", Reply::status(404))
1503 .route("/api/clip/only", Reply::json(&only));
1504 let mut client = scripted_client(&http, RecordingClock::new());
1505
1506 let clips =
1507 pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"])).unwrap();
1508 assert_eq!(clips.len(), 1, "the 404 id is skipped");
1509 assert_eq!(clips[0].id, "only");
1510 assert_eq!(http.count("/api/clip/only"), 1);
1512 assert_eq!(http.count("/api/clip/gone"), 1);
1513 }
1514
1515 #[test]
1516 fn get_clip_parent_reads_the_parent_clip() {
1517 let parent = serde_json::json!({
1518 "id": "par", "title": "Ancestor", "status": "complete",
1519 "metadata": {"type": "gen"}
1520 })
1521 .to_string();
1522 let mut rules = auth_rules();
1523 rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
1524 let http = MockHttp::new(rules);
1525 let mut client = authed_client(&http);
1526
1527 let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
1528 assert_eq!(clip.unwrap().id, "par");
1529 }
1530
1531 #[test]
1532 fn get_clip_parent_is_none_for_a_root() {
1533 let mut rules = auth_rules();
1534 rules.push(Rule::new(
1535 "/api/clips/parent",
1536 404,
1537 r#"{"detail": "no parent"}"#.to_string(),
1538 ));
1539 let http = MockHttp::new(rules);
1540 let mut client = authed_client(&http);
1541
1542 let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
1543 assert!(clip.is_none());
1544 }
1545
1546 #[test]
1547 fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
1548 for status in [500u16, 503] {
1552 let mut rules = auth_rules();
1553 rules.push(Rule::new(
1554 "/api/clips/parent",
1555 status,
1556 r#"{"detail": "server error"}"#.to_string(),
1557 ));
1558 let http = MockHttp::new(rules);
1559 let mut client = authed_client(&http);
1560
1561 let result = pollster::block_on(client.get_clip_parent(&http, "child"));
1562 assert!(
1563 matches!(result, Err(Error::Api(_))),
1564 "status {status} must propagate as an error, not Ok(None)"
1565 );
1566 }
1567 }
1568
1569 #[test]
1570 fn get_playlists_maps_entries_and_skips_missing_ids() {
1571 let page1 = serde_json::json!({
1572 "playlists": [
1573 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
1574 {"id": "", "name": "No Id", "num_total_results": 3},
1575 {"name": "Also No Id"}
1576 ]
1577 })
1578 .to_string();
1579 let mut rules = auth_rules();
1580 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1582 rules.push(Rule::new(
1583 "/api/playlist/me?page=2",
1584 200,
1585 r#"{"playlists": []}"#.to_string(),
1586 ));
1587 let http = MockHttp::new(rules);
1588 let mut client = authed_client(&http);
1589
1590 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1591 assert_eq!(playlists.len(), 1, "entries without an id are dropped");
1592 assert_eq!(
1593 playlists[0],
1594 Playlist {
1595 id: "pl1".to_owned(),
1596 name: "Road Trip".to_owned(),
1597 num_clips: 12,
1598 }
1599 );
1600 }
1601
1602 #[test]
1603 fn get_playlists_defaults_a_missing_name_to_untitled() {
1604 let page1 = serde_json::json!({
1605 "playlists": [{"id": "pl9", "num_total_results": 1}]
1606 })
1607 .to_string();
1608 let mut rules = auth_rules();
1609 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1610 rules.push(Rule::new(
1611 "/api/playlist/me?page=2",
1612 200,
1613 r#"{"playlists": []}"#.to_string(),
1614 ));
1615 let http = MockHttp::new(rules);
1616 let mut client = authed_client(&http);
1617
1618 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1619 assert_eq!(playlists[0].name, "Untitled");
1620 }
1621
1622 #[test]
1623 fn get_playlist_clips_preserves_order_and_unwraps_clip() {
1624 let body = serde_json::json!({
1627 "num_total_results": 2,
1628 "playlist_clips": [
1629 {"clip": {
1630 "id": "second", "title": "Second", "status": "complete",
1631 "metadata": {"duration": 60.0, "type": "gen"}
1632 }},
1633 {"clip": {
1634 "id": "first", "title": "First", "status": "complete",
1635 "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
1636 }}
1637 ]
1638 })
1639 .to_string();
1640 let mut rules = auth_rules();
1641 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1642 let http = MockHttp::new(rules);
1643 let mut client = authed_client(&http);
1644
1645 let (clips, complete) =
1646 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1647 assert_eq!(clips.len(), 2, "an infill member is not filtered out");
1648 assert_eq!(clips[0].id, "second");
1649 assert_eq!(clips[1].id, "first");
1650 assert!(
1651 complete,
1652 "returned == num_total_results is fully enumerated"
1653 );
1654 }
1655
1656 #[test]
1657 fn get_playlist_clips_short_page_is_not_complete() {
1658 let body = serde_json::json!({
1660 "num_total_results": 5,
1661 "playlist_clips": [
1662 {"clip": {
1663 "id": "only", "title": "Only", "status": "complete",
1664 "metadata": {"duration": 60.0, "type": "gen"}
1665 }}
1666 ]
1667 })
1668 .to_string();
1669 let mut rules = auth_rules();
1670 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1671 let http = MockHttp::new(rules);
1672 let mut client = authed_client(&http);
1673
1674 let (clips, complete) =
1675 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1676 assert_eq!(clips.len(), 1);
1677 assert!(!complete, "a short page is not fully enumerated");
1678 }
1679
1680 #[test]
1681 fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
1682 let mut rules = auth_rules();
1683 rules.push(Rule::new(
1684 "/api/playlist/empty/",
1685 200,
1686 r#"{"num_total_results": 0, "playlist_clips": []}"#.to_string(),
1687 ));
1688 let http = MockHttp::new(rules);
1689 let mut client = authed_client(&http);
1690
1691 let (clips, complete) =
1692 pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
1693 assert!(clips.is_empty());
1694 assert!(
1695 complete,
1696 "an empty playlist reporting zero total is complete"
1697 );
1698 }
1699
1700 #[test]
1701 fn get_playlist_clips_missing_total_is_not_complete() {
1702 let mut rules = auth_rules();
1706 rules.push(Rule::new(
1707 "/api/playlist/pl1/",
1708 200,
1709 r#"{"playlist_clips": []}"#.to_string(),
1710 ));
1711 let http = MockHttp::new(rules);
1712 let mut client = authed_client(&http);
1713
1714 let (clips, complete) =
1715 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1716 assert!(clips.is_empty());
1717 assert!(!complete, "a missing total is never fully enumerated");
1718 }
1719
1720 fn stem_page(stems: &[(&str, &str, &str)]) -> String {
1723 let entries: Vec<Value> = stems
1724 .iter()
1725 .map(|(id, label, url)| {
1726 serde_json::json!({
1727 "id": id,
1728 "title": format!("My Song ({label})"),
1729 "status": "complete",
1730 "audio_url": url,
1731 })
1732 })
1733 .collect();
1734 serde_json::json!({ "stems": entries }).to_string()
1735 }
1736
1737 fn stem_pages(pages: u32) -> String {
1739 serde_json::json!({ "pages": pages }).to_string()
1740 }
1741
1742 #[test]
1743 fn list_stems_drains_all_declared_pages_and_is_authoritative() {
1744 let http = ScriptedHttp::new()
1747 .with_auth()
1748 .route("stems/pages", Reply::json(&stem_pages(2)))
1749 .route(
1750 "stems?page=0",
1751 Reply::json(&stem_page(&[
1752 ("s1", "Vocals", "https://cdn1.suno.ai/s1.mp3"),
1753 ("s2", "Drums", "https://cdn1.suno.ai/s2.mp3"),
1754 ])),
1755 )
1756 .route(
1757 "stems?page=1",
1758 Reply::json(&stem_page(&[("s3", "Bass", "https://cdn1.suno.ai/s3.mp3")])),
1759 );
1760 let mut client = scripted_client(&http, RecordingClock::new());
1761
1762 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
1763 assert_eq!(stems.len(), 3);
1764 assert_eq!(stems[0].id, "s1");
1765 assert_eq!(stems[0].label, "Vocals");
1766 assert_eq!(stems[0].url, "https://cdn1.suno.ai/s1.mp3");
1767 assert_eq!(stems[2].label, "Bass");
1768 assert!(
1769 complete,
1770 "a fully drained listing that returned stems is authoritative"
1771 );
1772 }
1773
1774 #[test]
1775 fn list_stems_zero_pages_is_indeterminate_never_empty() {
1776 let http = ScriptedHttp::new()
1779 .with_auth()
1780 .route("stems/pages", Reply::json(&stem_pages(0)));
1781 let mut client = scripted_client(&http, RecordingClock::new());
1782
1783 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
1784 assert!(stems.is_empty());
1785 assert!(
1786 !complete,
1787 "an empty listing is indeterminate, so existing stems are kept"
1788 );
1789 }
1790
1791 #[test]
1792 fn list_stems_missing_page_count_is_indeterminate() {
1793 for status in [400u16, 404] {
1796 let http = ScriptedHttp::new()
1797 .with_auth()
1798 .route("stems/pages", Reply::status(status));
1799 let mut client = scripted_client(&http, RecordingClock::new());
1800 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
1801 assert!(stems.is_empty(), "status {status}");
1802 assert!(!complete, "status {status} is indeterminate, not empty");
1803 }
1804 }
1805
1806 #[test]
1807 fn stem_page_count_400_is_no_stems() {
1808 let http = ScriptedHttp::new()
1811 .with_auth()
1812 .route("stems/pages", Reply::status(400));
1813 let mut client = scripted_client(&http, RecordingClock::new());
1814
1815 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
1816 assert!(stems.is_empty());
1817 assert!(
1818 !complete,
1819 "400 is indeterminate, not an authoritative empty set"
1820 );
1821 }
1822
1823 #[test]
1824 fn stem_page_count_5xx_with_invalid_page_body_is_not_no_stems() {
1825 let http = ScriptedHttp::new()
1829 .with_auth()
1830 .route("stems/pages", Reply::with_body(500, "Invalid page"));
1831 let mut client = scripted_client(&http, RecordingClock::new());
1832
1833 let result = pollster::block_on(client.list_stems(&http, "clip1"));
1834 assert!(
1835 result.is_err(),
1836 "a 5xx is a transient error, never 'no stems'"
1837 );
1838 }
1839
1840 #[test]
1841 fn list_stems_page_error_mid_enumeration_propagates() {
1842 let http = ScriptedHttp::new()
1846 .with_auth()
1847 .route("stems/pages", Reply::json(&stem_pages(2)))
1848 .route(
1849 "stems?page=0",
1850 Reply::json(&stem_page(&[(
1851 "s1",
1852 "Vocals",
1853 "https://cdn1.suno.ai/s1.mp3",
1854 )])),
1855 )
1856 .route("stems?page=1", Reply::status(500));
1857 let mut client = scripted_client(&http, RecordingClock::new());
1858
1859 let result = pollster::block_on(client.list_stems(&http, "clip1"));
1860 assert!(result.is_err(), "a 5xx page is not a clean drain");
1861 }
1862
1863 #[test]
1864 fn list_stems_over_max_pages_is_truncated_never_authoritative() {
1865 let http = ScriptedHttp::new()
1870 .with_auth()
1871 .route("stems/pages", Reply::json(&stem_pages(MAX_PAGES + 1)))
1872 .route(
1873 "stems?page=",
1874 Reply::json(&stem_page(&[(
1875 "s1",
1876 "Vocals",
1877 "https://cdn1.suno.ai/s1.mp3",
1878 )])),
1879 );
1880 let mut client = scripted_client(&http, RecordingClock::new());
1881
1882 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
1883 assert!(!stems.is_empty(), "the fetched pages still yield stems");
1884 assert!(
1885 !complete,
1886 "a listing declaring more than MAX_PAGES is truncated, never authoritative"
1887 );
1888 }
1889
1890 #[test]
1891 fn parse_stems_page_maps_full_clips_and_skips_idless() {
1892 let page = stem_page(&[("x", "Backing Vocals", "https://cdn1.suno.ai/x.mp3")]);
1895 let stems = parse_stems_page(page.as_bytes());
1896 assert_eq!(stems.len(), 1);
1897 assert_eq!(stems[0].id, "x");
1898 assert_eq!(stems[0].label, "Backing Vocals");
1899 assert_eq!(stems[0].url, "https://cdn1.suno.ai/x.mp3");
1900 let no_id = br#"{"stems": [{"title": "Ghost (Vocals)", "audio_url": "https://cdn1.suno.ai/g.mp3"}]}"#;
1902 assert!(parse_stems_page(no_id).is_empty());
1903 let no_url = br#"{"stems": [{"id": "y", "title": "Song (Bass)"}]}"#;
1906 let recovered = parse_stems_page(no_url);
1907 assert_eq!(recovered.len(), 1);
1908 assert_eq!(recovered[0].url, "https://cdn1.suno.ai/y.mp3");
1909 assert!(parse_stems_page(b"not json").is_empty());
1911 }
1912
1913 #[test]
1914 fn parse_stem_page_count_reads_pages_field() {
1915 assert_eq!(parse_stem_page_count(br#"{"pages": 12}"#), 12);
1916 assert_eq!(parse_stem_page_count(br#"{"pages": 0}"#), 0);
1917 assert_eq!(parse_stem_page_count(br#"{}"#), 0);
1919 assert_eq!(parse_stem_page_count(br#"{"pages": -1}"#), 0);
1920 assert_eq!(parse_stem_page_count(b"not json"), 0);
1921 }
1922
1923 #[test]
1924 fn stem_label_from_title_extracts_trailing_parenthetical() {
1925 assert_eq!(stem_label_from_title("My Song (Vocals)"), "Vocals");
1926 assert_eq!(
1927 stem_label_from_title("A (b) Song (Backing Vocals)"),
1928 "Backing Vocals"
1929 );
1930 assert_eq!(stem_label_from_title("My Song (Drums) "), "Drums");
1931 assert_eq!(stem_label_from_title("My Song"), "");
1933 assert_eq!(stem_label_from_title(""), "");
1934 }
1935
1936 #[test]
1937 fn post_allow_list_permits_only_feed_and_wav_render() {
1938 assert!(post_path_allowed(FEED_V3_PATH));
1939 assert!(post_path_allowed("/api/gen/abc123/convert_wav/"));
1940 assert!(!post_path_allowed("/api/gen/abc123/stem_task"));
1942 assert!(!post_path_allowed("/api/gen/abc123/separate"));
1943 assert!(!post_path_allowed("/api/gen/a/../evil/convert_wav/"));
1945 assert!(!post_path_allowed("/api/gen/a/b/convert_wav/"));
1946 assert!(!post_path_allowed("/api/clip/x/stems/pages"));
1948 assert!(!post_path_allowed("/api/clip/x/stems?page=0"));
1949 }
1950
1951 #[test]
1952 fn api_request_refuses_a_post_off_the_allow_list() {
1953 let http = MockHttp::new(auth_rules());
1956 let mut client = authed_client(&http);
1957 let err = pollster::block_on(client.api_request(
1958 &http,
1959 Method::Post,
1960 "/api/gen/x/stem_task",
1961 b"{}".to_vec(),
1962 ))
1963 .unwrap_err();
1964 assert!(matches!(err, Error::Refused(_)));
1965 }
1966}