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(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 list_clips_reports_incomplete_when_paging_is_capped() {
912 let mut rules = auth_rules();
913 rules.push(Rule::new(
914 "/api/feed/v3",
915 200,
916 serde_json::json!({
917 "has_more": true,
918 "next_cursor": "cur1",
919 "clips": [{
920 "id": "a", "title": "Song A", "status": "complete",
921 "audio_url": "https://cdn1.suno.ai/a.mp3",
922 "metadata": {"type": "gen"}
923 }]
924 })
925 .to_string(),
926 ));
927 let http = MockHttp::new(rules);
928 let mut client = authed_client(&http);
929
930 let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
931 assert!(!complete);
932 }
933
934 fn auth_rules() -> Vec<Rule> {
935 let client_body = serde_json::json!({
936 "response": {
937 "last_active_session_id": "s",
938 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
939 }
940 })
941 .to_string();
942 vec![
943 Rule::new(
944 "/v1/client/sessions/",
945 200,
946 r#"{"jwt": "a.b.c"}"#.to_string(),
947 ),
948 Rule::new("/v1/client", 200, client_body),
949 ]
950 }
951
952 fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
953 let mut auth = ClerkAuth::new("eyJtoken");
954 pollster::block_on(auth.authenticate(http)).unwrap();
955 SunoClient::new(auth, RecordingClock::new())
956 }
957
958 #[test]
959 fn get_billing_info_reads_remaining_credits() {
960 let mut rules = auth_rules();
961 rules.push(Rule::new(
962 BILLING_INFO_PATH,
963 200,
964 r#"{"total_credits_left":500,"monthly_limit":1000,"monthly_usage":500}"#.to_string(),
965 ));
966 let http = MockHttp::new(rules);
967 let mut client = authed_client(&http);
968
969 let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
970 assert_eq!(billing.total_credits_left, 500);
971 }
972
973 #[test]
974 fn get_billing_info_rejects_missing_balance() {
975 let mut rules = auth_rules();
976 rules.push(Rule::new(
977 BILLING_INFO_PATH,
978 200,
979 r#"{"monthly_usage":12}"#.to_string(),
980 ));
981 let http = MockHttp::new(rules);
982 let mut client = authed_client(&http);
983
984 let err = pollster::block_on(client.get_billing_info(&http)).unwrap_err();
985 assert!(err.to_string().contains("total_credits_left"));
986 }
987
988 #[test]
989 fn aligned_lyrics_reads_words_and_lines() {
990 let mut rules = auth_rules();
991 let body = serde_json::json!({
992 "aligned_words": [
993 {"word": "hi", "success": true, "start_s": 0.5, "end_s": 0.9, "p_align": 0.99}
994 ],
995 "aligned_lyrics": [
996 {"text": "hi", "start_s": 0.5, "end_s": 0.9, "section": "Verse 1",
997 "words": [{"text": "hi", "start_s": 0.5, "end_s": 0.9}]}
998 ],
999 "hoot_cer": 0.2, "is_streamed": false
1000 })
1001 .to_string();
1002 rules.push(Rule::new("/aligned_lyrics/v2/", 200, body));
1003 let http = MockHttp::new(rules);
1004 let mut client = authed_client(&http);
1005
1006 let aligned = pollster::block_on(client.aligned_lyrics(&http, "clip-1")).unwrap();
1007 assert_eq!(aligned.words.len(), 1);
1008 assert_eq!(aligned.lines.len(), 1);
1009 assert_eq!(aligned.lines[0].section, "Verse 1");
1010 assert!(!aligned.is_empty());
1011 }
1012
1013 #[test]
1014 fn aligned_lyrics_empty_arrays_map_to_empty() {
1015 let mut rules = auth_rules();
1016 rules.push(Rule::new(
1017 "/aligned_lyrics/v2/",
1018 200,
1019 r#"{"aligned_words":[],"aligned_lyrics":[],"hoot_cer":1.0}"#.to_string(),
1020 ));
1021 let http = MockHttp::new(rules);
1022 let mut client = authed_client(&http);
1023
1024 let aligned = pollster::block_on(client.aligned_lyrics(&http, "instr")).unwrap();
1025 assert!(aligned.is_empty());
1026 }
1027
1028 #[test]
1029 fn aligned_lyrics_maps_404_to_empty() {
1030 let mut rules = auth_rules();
1031 rules.push(Rule::new(
1032 "/aligned_lyrics/v2/",
1033 404,
1034 "not found".to_string(),
1035 ));
1036 let http = MockHttp::new(rules);
1037 let mut client = authed_client(&http);
1038
1039 let aligned = pollster::block_on(client.aligned_lyrics(&http, "missing")).unwrap();
1040 assert!(aligned.is_empty());
1041 }
1042
1043 fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
1044 let mut auth = ClerkAuth::new("eyJtoken");
1045 pollster::block_on(auth.authenticate(http)).unwrap();
1046 SunoClient::new(auth, clock)
1047 }
1048
1049 fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
1050 let mut page = serde_json::json!({
1051 "has_more": next_cursor.is_some(),
1052 "clips": [{
1053 "id": id, "title": "Song", "status": "complete",
1054 "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
1055 "metadata": {"type": "gen"}
1056 }]
1057 });
1058 if let Some(cursor) = next_cursor {
1059 page["next_cursor"] = serde_json::json!(cursor);
1060 }
1061 page.to_string()
1062 }
1063
1064 #[test]
1065 fn list_clips_retries_a_rate_limited_page() {
1066 let http = ScriptedHttp::new().with_auth().route_seq(
1067 "/api/feed/v3",
1068 vec![Reply::status(429), Reply::json(&feed_body())],
1069 );
1070 let clock = RecordingClock::new();
1071 let mut client = scripted_client(&http, clock.clone());
1072
1073 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1074 assert_eq!(clips.len(), 1);
1075 assert!(complete);
1076 assert_eq!(http.count("/api/feed/v3"), 2);
1078 assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
1079 }
1080
1081 #[test]
1082 fn list_clips_honours_retry_after_on_a_throttled_page() {
1083 let http = ScriptedHttp::new().with_auth().route_seq(
1084 "/api/feed/v3",
1085 vec![
1086 Reply::status(429).with_retry_after(7),
1087 Reply::json(&feed_body()),
1088 ],
1089 );
1090 let clock = RecordingClock::new();
1091 let mut client = scripted_client(&http, clock.clone());
1092
1093 let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1094 assert_eq!(clips.len(), 1);
1095 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
1097 }
1098
1099 #[test]
1100 fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
1101 let http = ScriptedHttp::new().with_auth().route_seq(
1103 "/api/feed/v3",
1104 vec![
1105 Reply::json(&one_clip_page("a", Some("cur1"))),
1106 Reply::status(429),
1107 Reply::json(&one_clip_page("b", None)),
1108 ],
1109 );
1110 let clock = RecordingClock::new();
1111 let mut client = scripted_client(&http, clock.clone());
1112
1113 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1114 assert!(complete);
1115 assert_eq!(clips.len(), 2);
1116 let bodies = http.bodies();
1117 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
1118 assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
1119 let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
1122 let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
1123 assert_eq!(retried["cursor"], "cur1");
1124 assert_eq!(after_retry["cursor"], "cur1");
1125 }
1126
1127 #[test]
1128 fn list_clips_threads_the_cursor_across_pages() {
1129 let http = ScriptedHttp::new().with_auth().route_seq(
1130 "/api/feed/v3",
1131 vec![
1132 Reply::json(&one_clip_page("a", Some("cur1"))),
1133 Reply::json(&one_clip_page("b", None)),
1134 ],
1135 );
1136 let clock = RecordingClock::new();
1137 let mut client = scripted_client(&http, clock.clone());
1138
1139 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1140 assert!(complete);
1141 assert_eq!(clips.len(), 2);
1142 let bodies = http.bodies();
1143 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
1144 assert_eq!(feed_bodies.len(), 2);
1145 let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
1146 let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
1147 assert!(page1.get("cursor").is_none());
1149 assert_eq!(page2["cursor"], "cur1");
1150 }
1151
1152 #[test]
1153 fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
1154 let page = serde_json::json!({
1157 "has_more": true,
1158 "clips": [{
1159 "id": "a", "title": "Song", "status": "complete",
1160 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
1161 }]
1162 })
1163 .to_string();
1164 let http = ScriptedHttp::new()
1165 .with_auth()
1166 .route("/api/feed/v3", Reply::json(&page));
1167 let clock = RecordingClock::new();
1168 let mut client = scripted_client(&http, clock.clone());
1169
1170 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1171 assert!(!complete);
1172 assert_eq!(clips.len(), 1);
1173 assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
1174 }
1175
1176 #[test]
1177 fn list_clips_is_incomplete_when_has_more_is_missing() {
1178 let page = serde_json::json!({
1180 "clips": [{
1181 "id": "a", "title": "Song", "status": "complete",
1182 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
1183 }]
1184 })
1185 .to_string();
1186 let http = ScriptedHttp::new()
1187 .with_auth()
1188 .route("/api/feed/v3", Reply::json(&page));
1189 let clock = RecordingClock::new();
1190 let mut client = scripted_client(&http, clock.clone());
1191
1192 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1193 assert!(!complete);
1194 assert_eq!(clips.len(), 1);
1195 assert_eq!(http.count("/api/feed/v3"), 1);
1196 }
1197
1198 #[test]
1199 fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
1200 let http = ScriptedHttp::new().with_auth().route_seq(
1201 "/api/feed/v3",
1202 vec![
1203 Reply::json(&one_clip_page("a", Some("cur1"))),
1204 Reply::status(500),
1205 ],
1206 );
1207 let clock = RecordingClock::new();
1208 let mut client = scripted_client(&http, clock.clone());
1209
1210 let result = pollster::block_on(client.list_clips(&http, false, None));
1211 assert!(matches!(result, Err(Error::Api(_))));
1212 }
1213
1214 #[test]
1215 fn list_clips_is_complete_on_an_empty_drained_feed() {
1216 let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
1219 let http = ScriptedHttp::new()
1220 .with_auth()
1221 .route("/api/feed/v3", Reply::json(&page));
1222 let clock = RecordingClock::new();
1223 let mut client = scripted_client(&http, clock.clone());
1224
1225 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1226 assert!(complete);
1227 assert!(clips.is_empty());
1228 }
1229
1230 #[test]
1231 fn list_clips_liked_scope_sends_the_liked_filter() {
1232 let http = ScriptedHttp::new()
1233 .with_auth()
1234 .route("/api/feed/v3", Reply::json(&feed_body()));
1235 let clock = RecordingClock::new();
1236 let mut client = scripted_client(&http, clock.clone());
1237
1238 let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
1239 let bodies = http.bodies();
1240 let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
1241 let value: Value = serde_json::from_str(feed_body).unwrap();
1242 assert_eq!(value["filters"]["liked"], "True");
1243 assert_eq!(value["filters"]["trashed"], "False");
1244 }
1245
1246 #[test]
1247 fn list_clips_does_not_pace_an_unthrottled_walk() {
1248 let http = ScriptedHttp::new().with_auth().route_seq(
1249 "/api/feed/v3",
1250 vec![
1251 Reply::json(&one_clip_page("a", Some("cur1"))),
1252 Reply::json(&one_clip_page("e", None)),
1253 ],
1254 );
1255 let clock = RecordingClock::new();
1256 let mut client = scripted_client(&http, clock.clone());
1257
1258 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1259 assert!(complete);
1260 assert_eq!(clips.len(), 2);
1261 assert_eq!(http.count("/api/feed/v3"), 2);
1262 assert!(clock.sleeps().is_empty());
1264 }
1265
1266 #[test]
1267 fn list_clips_slows_its_pace_after_a_throttled_page() {
1268 let http = ScriptedHttp::new().with_auth().route_seq(
1269 "/api/feed/v3",
1270 vec![
1271 Reply::status(429),
1272 Reply::json(&one_clip_page("a", Some("cur1"))),
1273 Reply::json(&one_clip_page("e", None)),
1274 ],
1275 );
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_eq!(clips.len(), 2);
1282 assert_eq!(
1285 clock.sleeps(),
1286 vec![Duration::from_secs(5), Duration::from_secs(1)]
1287 );
1288 }
1289
1290 #[test]
1291 fn list_clips_gives_up_after_max_retries() {
1292 let http = ScriptedHttp::new()
1293 .with_auth()
1294 .route("/api/feed/v3", Reply::status(429));
1295 let clock = RecordingClock::new();
1296 let mut client = scripted_client(&http, clock.clone());
1297
1298 let result = pollster::block_on(client.list_clips(&http, false, None));
1299 assert!(matches!(result, Err(Error::RateLimited { .. })));
1300 let budget = crate::consts::API_MAX_RETRIES as usize;
1301 assert_eq!(clock.sleeps().len(), budget);
1302 assert_eq!(http.count("/api/feed/v3"), budget + 1);
1303 }
1304
1305 #[test]
1306 fn parse_clip_accepts_bare_and_wrapped_shapes() {
1307 let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
1308 assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
1309
1310 let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
1311 assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
1312
1313 let missing = serde_json::json!({"detail": "not found"}).to_string();
1314 assert!(parse_clip(missing.as_bytes()).is_none());
1315 }
1316
1317 #[test]
1318 fn get_clip_uses_the_dedicated_endpoint() {
1319 let clip_body = serde_json::json!({
1320 "id": "z", "title": "Zed", "status": "complete",
1321 "audio_url": "https://cdn1.suno.ai/z.mp3",
1322 "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
1323 })
1324 .to_string();
1325 let mut rules = auth_rules();
1326 rules.push(Rule::new("/api/clip/", 200, clip_body));
1327 let http = MockHttp::new(rules);
1328 let mut client = authed_client(&http);
1329
1330 let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
1331 assert_eq!(clip.id, "z");
1332 assert_eq!(clip.title, "Zed");
1333 assert_eq!(clip.tags, "jazz");
1334 }
1335
1336 #[test]
1337 fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
1338 let mut rules = auth_rules();
1339 rules.push(Rule::new(
1340 "/api/clip/",
1341 404,
1342 r#"{"detail": "not found"}"#.to_string(),
1343 ));
1344 rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
1345 let http = MockHttp::new(rules);
1346 let mut client = authed_client(&http);
1347
1348 let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
1349 assert_eq!(clip.id, "a");
1350 assert_eq!(clip.tags, "rock");
1351 }
1352
1353 #[test]
1354 fn request_wav_accepts_a_2xx_status() {
1355 let mut rules = auth_rules();
1356 rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
1357 let http = MockHttp::new(rules);
1358 let mut client = authed_client(&http);
1359
1360 assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
1361 }
1362
1363 #[test]
1364 fn wav_url_reads_the_ready_url() {
1365 let mut rules = auth_rules();
1366 rules.push(Rule::new(
1367 "/wav_file/",
1368 200,
1369 r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
1370 ));
1371 let http = MockHttp::new(rules);
1372 let mut client = authed_client(&http);
1373
1374 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1375 assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
1376 }
1377
1378 #[test]
1379 fn wav_url_is_none_until_the_render_is_ready() {
1380 let mut rules = auth_rules();
1381 rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
1382 let http = MockHttp::new(rules);
1383 let mut client = authed_client(&http);
1384
1385 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1386 assert_eq!(url, None);
1387 }
1388
1389 #[test]
1390 fn get_clips_by_ids_fetches_each_id_and_keeps_artefacts() {
1391 let p1 = serde_json::json!({
1395 "id": "p1", "title": "Infill Ancestor", "status": "complete",
1396 "metadata": {"type": "gen", "task": "infill"}
1397 })
1398 .to_string();
1399 let p2 = serde_json::json!({
1400 "id": "p2", "title": "Uploaded Root", "status": "complete",
1401 "metadata": {"type": "upload"}
1402 })
1403 .to_string();
1404 let mut rules = auth_rules();
1405 rules.push(Rule::new("/api/clip/p1", 200, p1));
1406 rules.push(Rule::new("/api/clip/p2", 200, p2));
1407 let http = MockHttp::new(rules);
1408 let mut client = authed_client(&http);
1409
1410 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"])).unwrap();
1411 assert_eq!(
1412 clips.len(),
1413 2,
1414 "infill and upload ancestors must not be filtered"
1415 );
1416 assert_eq!(clips[0].id, "p1");
1417 assert_eq!(clips[1].id, "p2");
1418 }
1419
1420 #[test]
1421 fn get_clips_by_ids_returns_a_trashed_clip() {
1422 let trashed = serde_json::json!({
1425 "id": "t1", "title": "Trashed Ancestor", "status": "complete",
1426 "is_trashed": true, "metadata": {"type": "gen"}
1427 })
1428 .to_string();
1429 let mut rules = auth_rules();
1430 rules.push(Rule::new("/api/clip/t1", 200, trashed));
1431 let http = MockHttp::new(rules);
1432 let mut client = authed_client(&http);
1433
1434 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"])).unwrap();
1435 assert_eq!(clips.len(), 1);
1436 assert_eq!(clips[0].id, "t1");
1437 assert!(clips[0].is_trashed);
1438 }
1439
1440 #[test]
1441 fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
1442 let only = serde_json::json!({
1443 "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
1444 })
1445 .to_string();
1446 let http = ScriptedHttp::new()
1447 .with_auth()
1448 .route("/api/clip/gone", Reply::status(404))
1449 .route("/api/clip/only", Reply::json(&only));
1450 let mut client = scripted_client(&http, RecordingClock::new());
1451
1452 let clips =
1453 pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"])).unwrap();
1454 assert_eq!(clips.len(), 1, "the 404 id is skipped");
1455 assert_eq!(clips[0].id, "only");
1456 assert_eq!(http.count("/api/clip/only"), 1);
1458 assert_eq!(http.count("/api/clip/gone"), 1);
1459 }
1460
1461 #[test]
1462 fn get_clip_parent_reads_the_parent_clip() {
1463 let parent = serde_json::json!({
1464 "id": "par", "title": "Ancestor", "status": "complete",
1465 "metadata": {"type": "gen"}
1466 })
1467 .to_string();
1468 let mut rules = auth_rules();
1469 rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
1470 let http = MockHttp::new(rules);
1471 let mut client = authed_client(&http);
1472
1473 let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
1474 assert_eq!(clip.unwrap().id, "par");
1475 }
1476
1477 #[test]
1478 fn get_clip_parent_is_none_for_a_root() {
1479 let mut rules = auth_rules();
1480 rules.push(Rule::new(
1481 "/api/clips/parent",
1482 404,
1483 r#"{"detail": "no parent"}"#.to_string(),
1484 ));
1485 let http = MockHttp::new(rules);
1486 let mut client = authed_client(&http);
1487
1488 let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
1489 assert!(clip.is_none());
1490 }
1491
1492 #[test]
1493 fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
1494 for status in [500u16, 503] {
1498 let mut rules = auth_rules();
1499 rules.push(Rule::new(
1500 "/api/clips/parent",
1501 status,
1502 r#"{"detail": "server error"}"#.to_string(),
1503 ));
1504 let http = MockHttp::new(rules);
1505 let mut client = authed_client(&http);
1506
1507 let result = pollster::block_on(client.get_clip_parent(&http, "child"));
1508 assert!(
1509 matches!(result, Err(Error::Api(_))),
1510 "status {status} must propagate as an error, not Ok(None)"
1511 );
1512 }
1513 }
1514
1515 #[test]
1516 fn get_playlists_maps_entries_and_skips_missing_ids() {
1517 let page1 = serde_json::json!({
1518 "playlists": [
1519 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
1520 {"id": "", "name": "No Id", "num_total_results": 3},
1521 {"name": "Also No Id"}
1522 ]
1523 })
1524 .to_string();
1525 let mut rules = auth_rules();
1526 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1528 rules.push(Rule::new(
1529 "/api/playlist/me?page=2",
1530 200,
1531 r#"{"playlists": []}"#.to_string(),
1532 ));
1533 let http = MockHttp::new(rules);
1534 let mut client = authed_client(&http);
1535
1536 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1537 assert_eq!(playlists.len(), 1, "entries without an id are dropped");
1538 assert_eq!(
1539 playlists[0],
1540 Playlist {
1541 id: "pl1".to_owned(),
1542 name: "Road Trip".to_owned(),
1543 num_clips: 12,
1544 }
1545 );
1546 }
1547
1548 #[test]
1549 fn get_playlists_defaults_a_missing_name_to_untitled() {
1550 let page1 = serde_json::json!({
1551 "playlists": [{"id": "pl9", "num_total_results": 1}]
1552 })
1553 .to_string();
1554 let mut rules = auth_rules();
1555 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1556 rules.push(Rule::new(
1557 "/api/playlist/me?page=2",
1558 200,
1559 r#"{"playlists": []}"#.to_string(),
1560 ));
1561 let http = MockHttp::new(rules);
1562 let mut client = authed_client(&http);
1563
1564 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1565 assert_eq!(playlists[0].name, "Untitled");
1566 }
1567
1568 #[test]
1569 fn get_playlist_clips_preserves_order_and_unwraps_clip() {
1570 let body = serde_json::json!({
1573 "num_total_results": 2,
1574 "playlist_clips": [
1575 {"clip": {
1576 "id": "second", "title": "Second", "status": "complete",
1577 "metadata": {"duration": 60.0, "type": "gen"}
1578 }},
1579 {"clip": {
1580 "id": "first", "title": "First", "status": "complete",
1581 "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
1582 }}
1583 ]
1584 })
1585 .to_string();
1586 let mut rules = auth_rules();
1587 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1588 let http = MockHttp::new(rules);
1589 let mut client = authed_client(&http);
1590
1591 let (clips, complete) =
1592 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1593 assert_eq!(clips.len(), 2, "an infill member is not filtered out");
1594 assert_eq!(clips[0].id, "second");
1595 assert_eq!(clips[1].id, "first");
1596 assert!(
1597 complete,
1598 "returned == num_total_results is fully enumerated"
1599 );
1600 }
1601
1602 #[test]
1603 fn get_playlist_clips_short_page_is_not_complete() {
1604 let body = serde_json::json!({
1606 "num_total_results": 5,
1607 "playlist_clips": [
1608 {"clip": {
1609 "id": "only", "title": "Only", "status": "complete",
1610 "metadata": {"duration": 60.0, "type": "gen"}
1611 }}
1612 ]
1613 })
1614 .to_string();
1615 let mut rules = auth_rules();
1616 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1617 let http = MockHttp::new(rules);
1618 let mut client = authed_client(&http);
1619
1620 let (clips, complete) =
1621 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1622 assert_eq!(clips.len(), 1);
1623 assert!(!complete, "a short page is not fully enumerated");
1624 }
1625
1626 #[test]
1627 fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
1628 let mut rules = auth_rules();
1629 rules.push(Rule::new(
1630 "/api/playlist/empty/",
1631 200,
1632 r#"{"num_total_results": 0, "playlist_clips": []}"#.to_string(),
1633 ));
1634 let http = MockHttp::new(rules);
1635 let mut client = authed_client(&http);
1636
1637 let (clips, complete) =
1638 pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
1639 assert!(clips.is_empty());
1640 assert!(
1641 complete,
1642 "an empty playlist reporting zero total is complete"
1643 );
1644 }
1645
1646 #[test]
1647 fn get_playlist_clips_missing_total_is_not_complete() {
1648 let mut rules = auth_rules();
1652 rules.push(Rule::new(
1653 "/api/playlist/pl1/",
1654 200,
1655 r#"{"playlist_clips": []}"#.to_string(),
1656 ));
1657 let http = MockHttp::new(rules);
1658 let mut client = authed_client(&http);
1659
1660 let (clips, complete) =
1661 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1662 assert!(clips.is_empty());
1663 assert!(!complete, "a missing total is never fully enumerated");
1664 }
1665
1666 fn stem_page(stems: &[(&str, &str, &str)]) -> String {
1669 let entries: Vec<Value> = stems
1670 .iter()
1671 .map(|(id, label, url)| {
1672 serde_json::json!({
1673 "id": id,
1674 "title": format!("My Song ({label})"),
1675 "status": "complete",
1676 "audio_url": url,
1677 })
1678 })
1679 .collect();
1680 serde_json::json!({ "stems": entries }).to_string()
1681 }
1682
1683 fn stem_pages(pages: u32) -> String {
1685 serde_json::json!({ "pages": pages }).to_string()
1686 }
1687
1688 #[test]
1689 fn list_stems_drains_all_declared_pages_and_is_authoritative() {
1690 let http = ScriptedHttp::new()
1693 .with_auth()
1694 .route("stems/pages", Reply::json(&stem_pages(2)))
1695 .route(
1696 "stems?page=0",
1697 Reply::json(&stem_page(&[
1698 ("s1", "Vocals", "https://cdn1.suno.ai/s1.mp3"),
1699 ("s2", "Drums", "https://cdn1.suno.ai/s2.mp3"),
1700 ])),
1701 )
1702 .route(
1703 "stems?page=1",
1704 Reply::json(&stem_page(&[("s3", "Bass", "https://cdn1.suno.ai/s3.mp3")])),
1705 );
1706 let mut client = scripted_client(&http, RecordingClock::new());
1707
1708 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
1709 assert_eq!(stems.len(), 3);
1710 assert_eq!(stems[0].id, "s1");
1711 assert_eq!(stems[0].label, "Vocals");
1712 assert_eq!(stems[0].url, "https://cdn1.suno.ai/s1.mp3");
1713 assert_eq!(stems[2].label, "Bass");
1714 assert!(
1715 complete,
1716 "a fully drained listing that returned stems is authoritative"
1717 );
1718 }
1719
1720 #[test]
1721 fn list_stems_zero_pages_is_indeterminate_never_empty() {
1722 let http = ScriptedHttp::new()
1725 .with_auth()
1726 .route("stems/pages", Reply::json(&stem_pages(0)));
1727 let mut client = scripted_client(&http, RecordingClock::new());
1728
1729 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
1730 assert!(stems.is_empty());
1731 assert!(
1732 !complete,
1733 "an empty listing is indeterminate, so existing stems are kept"
1734 );
1735 }
1736
1737 #[test]
1738 fn list_stems_missing_page_count_is_indeterminate() {
1739 for status in [400u16, 404] {
1742 let http = ScriptedHttp::new()
1743 .with_auth()
1744 .route("stems/pages", Reply::status(status));
1745 let mut client = scripted_client(&http, RecordingClock::new());
1746 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
1747 assert!(stems.is_empty(), "status {status}");
1748 assert!(!complete, "status {status} is indeterminate, not empty");
1749 }
1750 }
1751
1752 #[test]
1753 fn list_stems_page_error_mid_enumeration_propagates() {
1754 let http = ScriptedHttp::new()
1758 .with_auth()
1759 .route("stems/pages", Reply::json(&stem_pages(2)))
1760 .route(
1761 "stems?page=0",
1762 Reply::json(&stem_page(&[(
1763 "s1",
1764 "Vocals",
1765 "https://cdn1.suno.ai/s1.mp3",
1766 )])),
1767 )
1768 .route("stems?page=1", Reply::status(500));
1769 let mut client = scripted_client(&http, RecordingClock::new());
1770
1771 let result = pollster::block_on(client.list_stems(&http, "clip1"));
1772 assert!(result.is_err(), "a 5xx page is not a clean drain");
1773 }
1774
1775 #[test]
1776 fn list_stems_over_max_pages_is_truncated_never_authoritative() {
1777 let http = ScriptedHttp::new()
1782 .with_auth()
1783 .route("stems/pages", Reply::json(&stem_pages(MAX_PAGES + 1)))
1784 .route(
1785 "stems?page=",
1786 Reply::json(&stem_page(&[(
1787 "s1",
1788 "Vocals",
1789 "https://cdn1.suno.ai/s1.mp3",
1790 )])),
1791 );
1792 let mut client = scripted_client(&http, RecordingClock::new());
1793
1794 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
1795 assert!(!stems.is_empty(), "the fetched pages still yield stems");
1796 assert!(
1797 !complete,
1798 "a listing declaring more than MAX_PAGES is truncated, never authoritative"
1799 );
1800 }
1801
1802 #[test]
1803 fn parse_stems_page_maps_full_clips_and_skips_idless() {
1804 let page = stem_page(&[("x", "Backing Vocals", "https://cdn1.suno.ai/x.mp3")]);
1807 let stems = parse_stems_page(page.as_bytes());
1808 assert_eq!(stems.len(), 1);
1809 assert_eq!(stems[0].id, "x");
1810 assert_eq!(stems[0].label, "Backing Vocals");
1811 assert_eq!(stems[0].url, "https://cdn1.suno.ai/x.mp3");
1812 let no_id = br#"{"stems": [{"title": "Ghost (Vocals)", "audio_url": "https://cdn1.suno.ai/g.mp3"}]}"#;
1814 assert!(parse_stems_page(no_id).is_empty());
1815 let no_url = br#"{"stems": [{"id": "y", "title": "Song (Bass)"}]}"#;
1818 let recovered = parse_stems_page(no_url);
1819 assert_eq!(recovered.len(), 1);
1820 assert_eq!(recovered[0].url, "https://cdn1.suno.ai/y.mp3");
1821 assert!(parse_stems_page(b"not json").is_empty());
1823 }
1824
1825 #[test]
1826 fn parse_stem_page_count_reads_pages_field() {
1827 assert_eq!(parse_stem_page_count(br#"{"pages": 12}"#), 12);
1828 assert_eq!(parse_stem_page_count(br#"{"pages": 0}"#), 0);
1829 assert_eq!(parse_stem_page_count(br#"{}"#), 0);
1831 assert_eq!(parse_stem_page_count(br#"{"pages": -1}"#), 0);
1832 assert_eq!(parse_stem_page_count(b"not json"), 0);
1833 }
1834
1835 #[test]
1836 fn stem_label_from_title_extracts_trailing_parenthetical() {
1837 assert_eq!(stem_label_from_title("My Song (Vocals)"), "Vocals");
1838 assert_eq!(
1839 stem_label_from_title("A (b) Song (Backing Vocals)"),
1840 "Backing Vocals"
1841 );
1842 assert_eq!(stem_label_from_title("My Song (Drums) "), "Drums");
1843 assert_eq!(stem_label_from_title("My Song"), "");
1845 assert_eq!(stem_label_from_title(""), "");
1846 }
1847
1848 #[test]
1849 fn post_allow_list_permits_only_feed_and_wav_render() {
1850 assert!(post_path_allowed(FEED_V3_PATH));
1851 assert!(post_path_allowed("/api/gen/abc123/convert_wav/"));
1852 assert!(!post_path_allowed("/api/gen/abc123/stem_task"));
1854 assert!(!post_path_allowed("/api/gen/abc123/separate"));
1855 assert!(!post_path_allowed("/api/gen/a/../evil/convert_wav/"));
1857 assert!(!post_path_allowed("/api/gen/a/b/convert_wav/"));
1858 assert!(!post_path_allowed("/api/clip/x/stems/pages"));
1860 assert!(!post_path_allowed("/api/clip/x/stems?page=0"));
1861 }
1862
1863 #[test]
1864 fn api_request_refuses_a_post_off_the_allow_list() {
1865 let http = MockHttp::new(auth_rules());
1868 let mut client = authed_client(&http);
1869 let err = pollster::block_on(client.api_request(
1870 &http,
1871 Method::Post,
1872 "/api/gen/x/stem_task",
1873 b"{}".to_vec(),
1874 ))
1875 .unwrap_err();
1876 assert!(matches!(err, Error::Refused(_)));
1877 }
1878}