1use std::collections::{BTreeSet, HashMap};
4use std::sync::Mutex;
5use std::time::Instant;
6
7use futures_util::stream::{self, StreamExt};
8use serde_json::Value;
9
10use crate::auth::ClerkAuth;
11use crate::backoff::{backoff_delay, retry_after};
12use crate::clock::Clock;
13use crate::consts::{
14 API_MAX_RETRIES, BILLING_INFO_PATH, CLIP_PARENT_PATH, FEED_INITIAL_RATE, FEED_PAGE_SIZE,
15 FEED_V3_PATH, GET_SONGS_BY_IDS_PATH, GET_SONGS_CHUNK, MAX_PAGES, PLAYLIST_ME_PATH,
16 PLAYLIST_PATH, SUNO_API_BASE_URL,
17};
18use crate::error::{Error, Result};
19use crate::http::{Http, HttpRequest, Method};
20use crate::is_downloadable;
21use crate::limiter::{AdaptiveLimiter, retry_after_delay};
22use crate::lyrics::AlignedLyrics;
23use crate::model::Clip;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Playlist {
33 pub id: String,
35 pub name: String,
37 pub num_clips: u64,
39}
40
41#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub struct BillingInfo {
51 pub total_credits_left: Option<i64>,
53 pub monthly_limit: Option<i64>,
55 pub monthly_usage: Option<i64>,
57 pub credits: Option<i64>,
59 pub period: Option<String>,
61 pub period_end: Option<String>,
63 pub renews_on: Option<String>,
65 pub is_active: Option<bool>,
67 pub is_paused: Option<bool>,
69 pub is_past_due: Option<bool>,
71 pub is_gifted: Option<bool>,
73 pub subscription_platform: Option<String>,
75 pub plan_key: Option<String>,
77 pub plan_name: Option<String>,
79 pub plan_level: Option<i64>,
81 pub features: BTreeSet<String>,
84}
85
86impl BillingInfo {
87 pub fn has_feature(&self, name: &str) -> bool {
89 self.features.contains(name)
90 }
91
92 pub fn can_get_stems(&self) -> bool {
94 self.has_feature("get_stems")
95 }
96
97 pub fn can_convert_audio(&self) -> bool {
99 self.has_feature("convert_audio")
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Stem {
114 pub id: String,
117 pub label: String,
122 pub url: String,
124}
125
126pub struct SunoClient<C> {
135 auth: ClerkAuth,
136 clock: C,
137 limiter: Mutex<AdaptiveLimiter>,
138}
139
140impl<C: Clock> SunoClient<C> {
141 pub fn new(auth: ClerkAuth, clock: C) -> Self {
143 Self {
144 auth,
145 clock,
146 limiter: Mutex::new(AdaptiveLimiter::new(FEED_INITIAL_RATE)),
147 }
148 }
149
150 pub fn auth(&self) -> &ClerkAuth {
152 &self.auth
153 }
154
155 #[cfg(test)]
159 pub(crate) fn limiter_rate(&self) -> f64 {
160 self.limiter.lock().unwrap().rate()
161 }
162
163 pub async fn list_clips(
184 &self,
185 http: &impl Http,
186 liked: bool,
187 limit: Option<usize>,
188 ) -> Result<(Vec<Clip>, bool, bool)> {
189 let mut clips = Vec::new();
190 let mut cursor: Option<String> = None;
191 let mut complete = false;
192 let mut any_filtered = false;
193 for _ in 0..MAX_PAGES {
194 let body = feed_v3_body(liked, cursor.as_deref());
195 let response = self
196 .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
197 .await?;
198 let page = parse_feed_v3(&response)?;
199 clips.extend(page.clips);
200 any_filtered |= page.any_filtered;
201 match page.has_more {
202 Some(false) => {
203 complete = true;
204 break;
205 }
206 Some(true) => match page.next_cursor {
207 Some(next) => cursor = Some(next),
208 None => break,
209 },
210 None => break,
211 }
212 if limit.is_some_and(|n| clips.len() >= n) {
213 break;
214 }
215 }
216 if let Some(n) = limit {
217 clips.truncate(n);
218 }
219 Ok((clips, complete, any_filtered))
220 }
221
222 pub async fn get_clip(&self, http: &impl Http, id: &str) -> Result<Clip> {
227 if let Some(clip) = self.try_get_clip(http, id).await? {
228 return Ok(clip);
229 }
230 self.find_in_feed(http, id).await
231 }
232
233 pub async fn request_wav(&self, http: &impl Http, id: &str) -> Result<()> {
235 let path = format!("/api/gen/{id}/convert_wav/");
236 self.api_request(http, Method::Post, &path, Vec::new())
237 .await?;
238 Ok(())
239 }
240
241 pub async fn wav_url(&self, http: &impl Http, id: &str) -> Result<Option<String>> {
249 let path = format!("/api/gen/{id}/wav_file/");
250 let body = match self.api_get(http, &path).await {
251 Ok(body) => body,
252 Err(Error::NotFound(_)) => return Ok(None),
253 Err(err) => return Err(err),
254 };
255 let data: Value = serde_json::from_slice(&body)
256 .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
257 Ok(data
258 .get("wav_file_url")
259 .and_then(Value::as_str)
260 .filter(|url| !url.is_empty())
261 .map(str::to_string))
262 }
263
264 pub async fn aligned_lyrics(&self, http: &impl Http, id: &str) -> Result<AlignedLyrics> {
279 let path = format!("/api/gen/{id}/aligned_lyrics/v2/");
280 match self.api_get_retrying(http, &path).await {
281 Ok(body) => Ok(AlignedLyrics::from_bytes(&body)),
282 Err(Error::NotFound(_)) => Ok(AlignedLyrics::default()),
283 Err(err) => Err(err),
284 }
285 }
286
287 pub async fn get_clips_by_ids(
309 &self,
310 http: &impl Http,
311 ids: &[&str],
312 concurrency: usize,
313 ) -> Result<Vec<Clip>> {
314 let ordered = dedup_nonempty(ids);
315 let mut found: HashMap<&str, Clip> = self
316 .get_songs_by_ids(http, &ordered)
317 .await?
318 .into_iter()
319 .filter_map(|clip| {
320 ordered
321 .iter()
322 .find(|id| **id == clip.id)
323 .map(|id| (*id, clip))
324 })
325 .collect();
326 let omitted: Vec<&str> = ordered
327 .iter()
328 .copied()
329 .filter(|id| !found.contains_key(id))
330 .collect();
331 if !omitted.is_empty() {
332 for clip in self
333 .fetch_clips_individually(http, &omitted, concurrency)
334 .await?
335 {
336 if let Some(id) = ordered.iter().copied().find(|id| *id == clip.id) {
337 found.insert(id, clip);
338 }
339 }
340 }
341 Ok(ordered.iter().filter_map(|id| found.remove(id)).collect())
342 }
343
344 pub async fn get_songs_by_ids(&self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
365 let ordered = dedup_nonempty(ids);
366 let mut found: HashMap<&str, Clip> = HashMap::new();
367 for chunk in ordered.chunks(GET_SONGS_CHUNK) {
368 let query = chunk
369 .iter()
370 .map(|id| format!("ids={id}"))
371 .collect::<Vec<_>>()
372 .join("&");
373 let path = format!("{GET_SONGS_BY_IDS_PATH}?{query}");
374 let clips = match self.api_get_retrying(http, &path).await {
375 Ok(body) => parse_songs_batch(&body).unwrap_or_default(),
376 Err(err @ (Error::RateLimited { .. } | Error::Auth(_))) => return Err(err),
377 Err(_) => Vec::new(),
378 };
379 for clip in clips {
380 if let Some(id) = chunk.iter().copied().find(|id| *id == clip.id) {
381 found.insert(id, clip);
382 }
383 }
384 }
385 Ok(ordered.iter().filter_map(|id| found.remove(id)).collect())
386 }
387
388 async fn fetch_clips_individually(
397 &self,
398 http: &impl Http,
399 ids: &[&str],
400 concurrency: usize,
401 ) -> Result<Vec<Clip>> {
402 let limit = concurrency.max(1);
403 let fetched = stream::iter(ids.iter().copied())
404 .map(|id| async move {
405 let path = format!("/api/clip/{id}");
406 match self.api_get_retrying(http, &path).await {
407 Ok(body) => Ok(parse_clip(&body)),
408 Err(Error::NotFound(_)) => Ok(None),
409 Err(err) => Err(err),
410 }
411 })
412 .buffered(limit)
413 .collect::<Vec<_>>()
414 .await;
415 let mut clips = Vec::new();
416 for item in fetched {
417 if let Some(clip) = item? {
418 clips.push(clip);
419 }
420 }
421 Ok(clips)
422 }
423
424 pub async fn get_clip_parent(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
434 let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
435 match self.api_get_retrying(http, &path).await {
436 Ok(body) => Ok(parse_clip(&body)),
439 Err(Error::NotFound(_)) => Ok(None),
440 Err(err) => Err(err),
441 }
442 }
443
444 pub async fn get_playlists(&self, http: &impl Http) -> Result<Vec<Playlist>> {
457 let mut playlists = Vec::new();
458 let mut seen = BTreeSet::new();
459 for page in 1..=MAX_PAGES {
460 let path =
461 format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
462 let body = self.api_get_retrying(http, &path).await?;
463 let page_playlists = parse_playlists(&body)?;
464 if page_playlists.is_empty() {
465 break;
466 }
467 for playlist in page_playlists {
468 if seen.insert(playlist.id.clone()) {
469 playlists.push(playlist);
470 }
471 }
472 }
473 Ok(playlists)
474 }
475
476 pub async fn get_playlist_clips(
493 &self,
494 http: &impl Http,
495 id: &str,
496 ) -> Result<(Vec<Clip>, bool)> {
497 let path = format!("{PLAYLIST_PATH}{id}/");
498 let body = self.api_get_retrying(http, &path).await?;
499 parse_playlist_clips(&body)
500 }
501
502 pub async fn get_billing_info(&self, http: &impl Http) -> Result<BillingInfo> {
504 let body = self.api_get_retrying(http, BILLING_INFO_PATH).await?;
505 parse_billing_info(&body)
506 }
507
508 pub async fn list_stems(&self, http: &impl Http, clip_id: &str) -> Result<(Vec<Stem>, bool)> {
532 let declared = self.stem_page_count(http, clip_id).await?;
533 if declared == 0 {
536 return Ok((Vec::new(), false));
537 }
538 let pages = declared.min(MAX_PAGES);
539 let mut stems: Vec<Stem> = Vec::new();
540 for page in 0..pages {
541 let path = format!("/api/clip/{clip_id}/stems?page={page}");
544 let body = self.api_get_retrying(http, &path).await?;
548 stems.extend(parse_stems_page(&body));
549 }
550 dedupe_stems(&mut stems);
551 let complete = !stems.is_empty() && declared <= MAX_PAGES;
557 Ok((stems, complete))
558 }
559
560 async fn stem_page_count(&self, http: &impl Http, clip_id: &str) -> Result<u32> {
568 let path = format!("/api/clip/{clip_id}/stems/pages");
569 match self.api_get_retrying(http, &path).await {
570 Ok(body) => Ok(parse_stem_page_count(&body)),
571 Err(err) if is_invalid_page_error(&err) => Ok(0),
572 Err(Error::NotFound(_)) => Ok(0),
573 Err(err) => Err(err),
574 }
575 }
576
577 async fn try_get_clip(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
580 let path = format!("/api/clip/{id}");
581 match self.api_get_retrying(http, &path).await {
582 Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
583 Err(Error::NotFound(_)) => Ok(None),
584 Err(err) => Err(err),
585 }
586 }
587
588 async fn find_in_feed(&self, http: &impl Http, id: &str) -> Result<Clip> {
590 let (clips, _complete, _) = self.list_clips(http, false, None).await?;
591 clips
592 .into_iter()
593 .find(|clip| clip.id == id)
594 .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
595 }
596
597 async fn api_get(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
599 self.api_request(http, Method::Get, path, Vec::new()).await
600 }
601
602 async fn api_get_retrying(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
604 self.api_send_retrying(http, Method::Get, path, Vec::new())
605 .await
606 }
607
608 async fn api_send_retrying(
628 &self,
629 http: &impl Http,
630 method: Method,
631 path: &str,
632 body: Vec<u8>,
633 ) -> Result<Vec<u8>> {
634 let pace = self.limiter.lock().unwrap().pace(Instant::now());
635 if !pace.is_zero() {
636 self.clock.sleep(pace).await;
637 }
638 let mut retries = 0;
639 loop {
640 match self.api_request(http, method, path, body.clone()).await {
641 Ok(response) => return Ok(response),
642 Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
643 self.clock.sleep(retry_after_delay(retry_after)).await;
644 retries += 1;
645 }
646 Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
647 self.clock.sleep(backoff_delay(retries, None)).await;
648 retries += 1;
649 }
650 Err(err) => return Err(err),
651 }
652 }
653 }
654
655 async fn api_request(
660 &self,
661 http: &impl Http,
662 method: Method,
663 path: &str,
664 body: Vec<u8>,
665 ) -> Result<Vec<u8>> {
666 if method == Method::Post && !post_path_allowed(path) {
672 return Err(Error::Refused(format!(
673 "POST to {path} is not on the allow-list"
674 )));
675 }
676 let url = format!("{SUNO_API_BASE_URL}{path}");
677 let mut auth_refreshed = false;
678 loop {
679 let jwt = self.auth.ensure_jwt(self.clock.now_unix(), http).await?;
680 let mut request = match method {
681 Method::Get => HttpRequest::get(url.clone()),
682 Method::Post => HttpRequest::post(url.clone(), body.clone()),
683 };
684 request
685 .headers
686 .push(("Authorization".to_string(), format!("Bearer {jwt}")));
687 let response = http
688 .send(request)
689 .await
690 .map_err(|err| Error::Connection(err.to_string()))?;
691 match response.status {
692 200..=299 => {
693 self.limiter.lock().unwrap().on_success();
694 return Ok(response.body);
695 }
696 401 | 403 if !auth_refreshed => {
697 self.auth.invalidate_jwt();
698 auth_refreshed = true;
699 }
700 401 | 403 => {
701 return Err(Error::Auth(format!(
702 "Suno API auth failed with status {}",
703 response.status
704 )));
705 }
706 429 => {
707 self.limiter.lock().unwrap().on_rate_limit();
708 return Err(Error::RateLimited {
709 retry_after: retry_after(&response),
710 });
711 }
712 400 => {
713 let preview: String = String::from_utf8_lossy(&response.body)
714 .chars()
715 .take(200)
716 .collect();
717 return Err(Error::BadRequest(format!(
718 "Suno API returned 400: {preview}"
719 )));
720 }
721 404 => {
722 return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
723 }
724 status => {
725 let preview: String = String::from_utf8_lossy(&response.body)
726 .chars()
727 .take(200)
728 .collect();
729 return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
730 }
731 }
732 }
733 }
734}
735
736fn unwrap_clip(value: &Value) -> &Value {
739 value
740 .get("clip")
741 .filter(|clip| clip.is_object())
742 .unwrap_or(value)
743}
744
745fn post_path_allowed(path: &str) -> bool {
755 if path == FEED_V3_PATH {
756 return true;
757 }
758 if let Some(rest) = path.strip_prefix("/api/gen/")
760 && let Some(id) = rest.strip_suffix("/convert_wav/")
761 {
762 return is_single_id_segment(id);
763 }
764 false
765}
766
767fn is_single_id_segment(segment: &str) -> bool {
771 !segment.is_empty()
772 && !segment.contains('/')
773 && !segment.contains('?')
774 && !segment.contains("..")
775}
776
777fn is_invalid_page_error(err: &Error) -> bool {
782 matches!(err, Error::BadRequest(_))
783}
784
785fn parse_stem_page_count(body: &[u8]) -> u32 {
791 serde_json::from_slice::<Value>(body)
792 .ok()
793 .and_then(|data| data.get("pages").and_then(Value::as_u64))
794 .and_then(|pages| u32::try_from(pages).ok())
795 .unwrap_or(0)
796}
797
798fn parse_stems_page(body: &[u8]) -> Vec<Stem> {
808 let Ok(data) = serde_json::from_slice::<Value>(body) else {
809 return Vec::new();
810 };
811 let items = if let Some(array) = data.as_array() {
812 array.as_slice()
813 } else {
814 data.get("stems")
815 .and_then(Value::as_array)
816 .map(Vec::as_slice)
817 .unwrap_or(&[])
818 };
819 items
820 .iter()
821 .map(parse_stem)
822 .filter(|stem| !stem.id.is_empty() && !stem.url.is_empty())
823 .collect()
824}
825
826fn parse_stem(raw: &Value) -> Stem {
829 let clip = Clip::from_json(raw);
830 Stem {
831 id: clip.id.clone(),
832 label: stem_label(&clip),
833 url: clip.mp3_url(),
834 }
835}
836
837fn stem_label(clip: &Clip) -> String {
842 let group = clip.stem_type_group_name.replace('_', " ");
843 let group = group.trim();
844 if !group.is_empty() {
845 return group.to_string();
846 }
847 stem_label_from_title(&clip.title)
848}
849
850fn stem_label_from_title(title: &str) -> String {
855 let trimmed = title.trim_end();
856 let Some(before_close) = trimmed.strip_suffix(')') else {
857 return String::new();
858 };
859 match before_close.rfind('(') {
860 Some(open) => before_close[open + 1..].trim().to_string(),
861 None => String::new(),
862 }
863}
864
865fn dedupe_stems(stems: &mut Vec<Stem>) {
868 let mut seen = BTreeSet::new();
869 stems.retain(|stem| seen.insert(stem.url.clone()));
870}
871
872fn parse_clip(body: &[u8]) -> Option<Clip> {
875 let data: Value = serde_json::from_slice(body).ok()?;
876 let raw = unwrap_clip(&data);
877 let has_id = raw
878 .get("id")
879 .and_then(Value::as_str)
880 .is_some_and(|id| !id.is_empty());
881 has_id.then(|| Clip::from_json(raw))
882}
883
884fn dedup_nonempty<'a>(ids: &[&'a str]) -> Vec<&'a str> {
887 let mut seen: BTreeSet<&str> = BTreeSet::new();
888 ids.iter()
889 .copied()
890 .filter(|id| !id.is_empty() && seen.insert(id))
891 .collect()
892}
893
894fn parse_songs_batch(body: &[u8]) -> Option<Vec<Clip>> {
899 let data: Value = serde_json::from_slice(body).ok()?;
900 let clips = data.get("clips")?.as_array()?;
901 Some(
902 clips
903 .iter()
904 .map(Clip::from_json)
905 .filter(|clip| !clip.id.is_empty())
906 .collect(),
907 )
908}
909
910fn parse_billing_info(body: &[u8]) -> Result<BillingInfo> {
915 let data: Value = serde_json::from_slice(body)
916 .map_err(|err| Error::Api(format!("invalid billing JSON: {err}")))?;
917 Ok(from_billing_json(&data))
918}
919
920fn from_billing_json(data: &Value) -> BillingInfo {
927 let plan = data.get("plan");
928 let mut features = BTreeSet::new();
929 collect_feature_names(data.get("accessible_features"), &mut features);
930 collect_feature_names(
931 plan.and_then(|plan| plan.get("usage_plan_features")),
932 &mut features,
933 );
934 BillingInfo {
935 total_credits_left: data.get("total_credits_left").and_then(json_i64),
936 monthly_limit: data.get("monthly_limit").and_then(json_i64),
937 monthly_usage: data.get("monthly_usage").and_then(json_i64),
938 credits: data.get("credits").and_then(json_i64),
939 period: json_string(data.get("period")),
940 period_end: json_string(data.get("period_end")),
941 renews_on: json_string(data.get("renews_on")),
942 is_active: data.get("is_active").and_then(Value::as_bool),
943 is_paused: data.get("is_paused").and_then(Value::as_bool),
944 is_past_due: data.get("is_past_due").and_then(Value::as_bool),
945 is_gifted: data.get("is_gifted").and_then(Value::as_bool),
946 subscription_platform: json_string(data.get("subscription_platform")),
947 plan_key: json_string(plan.and_then(|plan| plan.get("plan_key"))),
948 plan_name: json_string(plan.and_then(|plan| plan.get("name"))),
949 plan_level: plan.and_then(|plan| plan.get("level")).and_then(json_i64),
950 features,
951 }
952}
953
954fn collect_feature_names(array: Option<&Value>, out: &mut BTreeSet<String>) {
957 let Some(items) = array.and_then(Value::as_array) else {
958 return;
959 };
960 for name in items
961 .iter()
962 .filter_map(|item| item.get("name").and_then(Value::as_str))
963 {
964 if !name.is_empty() {
965 out.insert(name.to_owned());
966 }
967 }
968}
969
970fn json_string(value: Option<&Value>) -> Option<String> {
972 value.and_then(Value::as_str).map(str::to_owned)
973}
974
975fn json_i64(value: &Value) -> Option<i64> {
981 match value {
982 Value::Number(number) => number
983 .as_i64()
984 .or_else(|| number.as_f64().and_then(f64_to_i64)),
985 Value::String(text) => str_to_i64(text),
986 _ => None,
987 }
988}
989
990fn f64_to_i64(value: f64) -> Option<i64> {
993 if value.is_finite() && value.fract() == 0.0 && value.abs() < 9_007_199_254_740_992.0 {
997 Some(value as i64)
998 } else {
999 None
1000 }
1001}
1002
1003fn str_to_i64(text: &str) -> Option<i64> {
1006 match text.split_once('.') {
1007 Some((integer, fraction)) => {
1008 let integral = fraction.is_empty() || fraction.bytes().all(|byte| byte == b'0');
1009 integral.then(|| integer.parse().ok()).flatten()
1010 }
1011 None => text.parse().ok(),
1012 }
1013}
1014
1015fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
1022 let mut filters = serde_json::Map::new();
1023 filters.insert("trashed".to_string(), Value::String("False".to_string()));
1024 if liked {
1025 filters.insert("liked".to_string(), Value::String("True".to_string()));
1026 }
1027 let mut body = serde_json::Map::new();
1028 body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
1029 body.insert("filters".to_string(), Value::Object(filters));
1030 if let Some(cursor) = cursor {
1031 body.insert("cursor".to_string(), Value::String(cursor.to_string()));
1032 }
1033 serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
1034}
1035
1036struct FeedPage {
1045 clips: Vec<Clip>,
1046 has_more: Option<bool>,
1047 next_cursor: Option<String>,
1048 any_filtered: bool,
1049}
1050
1051fn parse_feed_v3(body: &[u8]) -> Result<FeedPage> {
1053 let data: Value = serde_json::from_slice(body)
1054 .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
1055 let Some(object) = data.as_object() else {
1056 return Ok(FeedPage {
1057 clips: Vec::new(),
1058 has_more: None,
1059 next_cursor: None,
1060 any_filtered: false,
1061 });
1062 };
1063 let raw = object.get("clips").and_then(Value::as_array);
1064 let raw_len = raw.map(|clips| clips.len()).unwrap_or(0);
1065 let clips: Vec<Clip> = raw
1066 .map(|raw| {
1067 raw.iter()
1068 .map(Clip::from_json)
1069 .filter(is_downloadable)
1070 .filter(|clip| !clip.id.is_empty())
1071 .collect()
1072 })
1073 .unwrap_or_default();
1074 let any_filtered = clips.len() < raw_len;
1081 let has_more = object.get("has_more").and_then(Value::as_bool);
1082 let next_cursor = object
1083 .get("next_cursor")
1084 .and_then(Value::as_str)
1085 .filter(|cursor| !cursor.is_empty())
1086 .map(str::to_string);
1087 Ok(FeedPage {
1088 clips,
1089 has_more,
1090 next_cursor,
1091 any_filtered,
1092 })
1093}
1094
1095fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
1097 let data: Value = serde_json::from_slice(body)
1098 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
1099 Ok(data
1100 .get("playlists")
1101 .and_then(Value::as_array)
1102 .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
1103 .unwrap_or_default())
1104}
1105
1106fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
1111 let id = raw
1112 .get("id")
1113 .and_then(Value::as_str)
1114 .filter(|id| !id.is_empty())?
1115 .to_string();
1116 let name = match raw.get("name") {
1117 Some(Value::String(name)) if !name.is_empty() => name.clone(),
1118 _ => "Untitled".to_string(),
1119 };
1120 let num_clips = raw
1121 .get("num_total_results")
1122 .and_then(Value::as_u64)
1123 .unwrap_or(0);
1124 Some(Playlist {
1125 id,
1126 name,
1127 num_clips,
1128 })
1129}
1130
1131fn parse_playlist_clips(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
1149 let data: Value = serde_json::from_slice(body)
1150 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
1151 let raw = data.get("playlist_clips").and_then(Value::as_array);
1152 let raw_len = raw.map(|a| a.len()).unwrap_or(0);
1153 let clips: Vec<Clip> = raw
1154 .map(|raw| {
1155 raw.iter()
1156 .map(|entry| Clip::from_json(unwrap_clip(entry)))
1157 .filter(|clip| !clip.id.is_empty())
1158 .collect()
1159 })
1160 .unwrap_or_default();
1161 let complete = data
1168 .get("num_total_results")
1169 .and_then(Value::as_u64)
1170 .is_some_and(|total| raw_len as u64 == total && clips.len() == raw_len);
1171 Ok((clips, complete))
1172}
1173
1174#[cfg(test)]
1175mod tests {
1176 use super::*;
1177 use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
1178 use std::time::Duration;
1179
1180 fn feed_body() -> String {
1181 serde_json::json!({
1182 "has_more": false,
1183 "clips": [
1184 {
1185 "id": "a", "title": "Song A", "status": "complete",
1186 "audio_url": "https://cdn1.suno.ai/a.mp3",
1187 "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
1188 },
1189 {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
1190 {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
1191 {
1192 "id": "d", "title": "Context", "status": "complete",
1193 "metadata": {"type": "rendered_context_window"}
1194 }
1195 ]
1196 })
1197 .to_string()
1198 }
1199
1200 #[test]
1201 fn parse_feed_v3_filters_and_reads_pagination() {
1202 let page = parse_feed_v3(feed_body().as_bytes()).unwrap();
1203 assert_eq!(page.has_more, Some(false));
1204 assert_eq!(page.next_cursor, None);
1205 assert_eq!(page.clips.len(), 1);
1206 assert_eq!(page.clips[0].id, "a");
1207 assert_eq!(page.clips[0].tags, "rock");
1208 assert!((page.clips[0].duration - 120.5).abs() < f64::EPSILON);
1209 }
1210
1211 #[test]
1212 fn parse_feed_v3_flags_a_dropped_clip_as_filtered() {
1213 let page = parse_feed_v3(feed_body().as_bytes()).unwrap();
1217 assert_eq!(page.clips.len(), 1);
1218 assert!(page.any_filtered);
1219
1220 let clean = serde_json::json!({
1222 "has_more": false,
1223 "clips": [{"id": "a", "status": "complete", "metadata": {"type": "gen"}}]
1224 })
1225 .to_string();
1226 let page = parse_feed_v3(clean.as_bytes()).unwrap();
1227 assert_eq!(page.clips.len(), 1);
1228 assert!(!page.any_filtered);
1229
1230 let empty_id = serde_json::json!({
1234 "has_more": false,
1235 "clips": [
1236 {"id": "kept", "status": "complete", "metadata": {"type": "gen"}},
1237 {"id": "", "status": "complete", "metadata": {"type": "gen"}}
1238 ]
1239 })
1240 .to_string();
1241 let page = parse_feed_v3(empty_id.as_bytes()).unwrap();
1242 assert_eq!(page.clips.len(), 1);
1243 assert_eq!(page.clips[0].id, "kept");
1244 assert!(page.any_filtered);
1245 }
1246
1247 const FEED_V3_PAGE: &str = r#"{
1251 "clips": [
1252 {
1253 "status": "complete",
1254 "title": "Track 31",
1255 "id": "00000000-0000-4000-8000-000000000076",
1256 "entity_type": "song_schema",
1257 "video_url": "",
1258 "audio_url": "https://cdn1.suno.ai/00000000-0000-4000-8000-000000000076.mp3",
1259 "media_urls": [
1260 {
1261 "url": "https://media.cloudfront.net/1/clip/00000000-0000-4000-8000-000000000076.m4a",
1262 "content_type": "m4a-opus",
1263 "delivery": "progressive",
1264 "encoding": "1.0.0"
1265 },
1266 {
1267 "url": "https://cdn1.suno.ai/00000000-0000-4000-8000-000000000076.mp3",
1268 "content_type": "mp3",
1269 "delivery": "progressive"
1270 }
1271 ],
1272 "image_url": "https://cdn2.suno.ai/image_00000000-0000-4000-8000-000000000076.jpeg",
1273 "image_large_url": "https://cdn2.suno.ai/image_large_00000000-0000-4000-8000-000000000076.jpeg",
1274 "major_model_version": "v4.5",
1275 "model_name": "chirp-ahi",
1276 "metadata": {
1277 "tags": "",
1278 "type": "gen",
1279 "duration": 272.0,
1280 "task": "gen_stem",
1281 "has_stem": false
1282 },
1283 "is_liked": false,
1284 "user_id": "00000000-0000-4000-8000-000000000019",
1285 "display_name": "Example Artist 4",
1286 "handle": "example-artist-1",
1287 "is_trashed": false,
1288 "is_hidden": false,
1289 "created_at": "2026-07-03T13:15:10.635Z",
1290 "is_public": false,
1291 "explicit": false,
1292 "batch_index": 23,
1293 "clip_roots": {
1294 "clips": [
1295 {
1296 "id": "00000000-0000-4000-8000-000000000028",
1297 "title": "Track 7",
1298 "image_url": "https://cdn2.suno.ai/image_00000000-0000-4000-8000-000000000028.jpeg",
1299 "is_public": false,
1300 "user_display_name": "Example Artist 4",
1301 "user_handle": "example-artist-1",
1302 "user_avatar_image_url": "https://cdn1.suno.ai/avatar.jpg"
1303 }
1304 ],
1305 "clip_attribution_type": "remix"
1306 }
1307 }
1308 ],
1309 "has_more": true,
1310 "next_cursor": "cursor-token"
1311 }"#;
1312
1313 #[test]
1314 fn parse_feed_v3_page_maps_real_body_and_pagination() {
1315 let FeedPage {
1316 clips,
1317 has_more,
1318 next_cursor,
1319 ..
1320 } = parse_feed_v3(FEED_V3_PAGE.as_bytes()).unwrap();
1321 assert_eq!(has_more, Some(true));
1322 assert_eq!(next_cursor.as_deref(), Some("cursor-token"));
1323 assert_eq!(clips.len(), 1);
1325 let clip = &clips[0];
1326 assert_eq!(clip.id, "00000000-0000-4000-8000-000000000076");
1327 assert_eq!(clip.title, "Track 31");
1328 assert_eq!(clip.model_name, "chirp-ahi");
1329 assert_eq!(clip.major_model_version, "v4.5");
1330 assert_eq!(clip.user_id, "00000000-0000-4000-8000-000000000019");
1331 assert_eq!(clip.batch_index, Some(23));
1332 assert_eq!(
1334 clip.image_url,
1335 "https://cdn1.suno.ai/image_00000000-0000-4000-8000-000000000076.jpeg"
1336 );
1337 assert!(clip.image_large_url.starts_with("https://cdn1.suno.ai/"));
1338 assert_eq!(clip.media_urls.len(), 2);
1340 assert_eq!(clip.media_urls[0].content_type, "m4a-opus");
1341 assert_eq!(
1342 clip.mp3_url(),
1343 "https://cdn1.suno.ai/00000000-0000-4000-8000-000000000076.mp3"
1344 );
1345 assert_eq!(clip.clip_attribution_type, "remix");
1347 assert_eq!(clip.clip_roots.len(), 1);
1348 assert_eq!(
1349 clip.clip_roots[0].id,
1350 "00000000-0000-4000-8000-000000000028"
1351 );
1352 assert_eq!(clip.clip_roots[0].handle, "example-artist-1");
1353 }
1354
1355 #[test]
1356 fn parse_feed_v3_page_survives_stripped_optional_fields() {
1357 let stripped = serde_json::json!({
1360 "clips": [{
1361 "id": "bare", "title": "Bare", "status": "complete",
1362 "metadata": {"type": "gen"}
1363 }],
1364 "has_more": false
1365 })
1366 .to_string();
1367 let FeedPage {
1368 clips,
1369 has_more,
1370 next_cursor,
1371 ..
1372 } = parse_feed_v3(stripped.as_bytes()).unwrap();
1373 assert_eq!(has_more, Some(false));
1374 assert_eq!(next_cursor, None);
1375 assert_eq!(clips.len(), 1);
1376 assert!(clips[0].media_urls.is_empty());
1377 assert_eq!(clips[0].user_id, "");
1378 assert_eq!(clips[0].batch_index, None);
1379 }
1380
1381 #[test]
1382 fn feed_v3_body_carries_filters_and_optional_cursor() {
1383 let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
1384 assert_eq!(first["filters"]["trashed"], "False");
1385 assert!(first.get("cursor").is_none());
1386 assert!(first["filters"].get("liked").is_none());
1387
1388 let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
1389 assert_eq!(liked["filters"]["liked"], "True");
1390 assert_eq!(liked["cursor"], "cur42");
1391 }
1392
1393 #[test]
1394 fn audiopipe_url_is_rewritten_to_cdn() {
1395 let raw =
1396 serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
1397 assert_eq!(
1398 Clip::from_json(&raw).audio_url,
1399 "https://cdn1.suno.ai/x.mp3"
1400 );
1401 }
1402
1403 #[test]
1404 fn list_clips_authenticates_then_reads_the_feed() {
1405 let client_body = serde_json::json!({
1406 "response": {
1407 "last_active_session_id": "s",
1408 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
1409 }
1410 })
1411 .to_string();
1412 let http = MockHttp::new(vec![
1413 Rule::new(
1414 "/v1/client/sessions/",
1415 200,
1416 r#"{"jwt": "a.b.c"}"#.to_string(),
1417 ),
1418 Rule::new("/v1/client", 200, client_body),
1419 Rule::new("/api/feed/v3", 200, feed_body()),
1420 ]);
1421
1422 let auth = ClerkAuth::new("eyJtoken");
1423 pollster::block_on(auth.authenticate(&http)).unwrap();
1424 let client = SunoClient::new(auth, RecordingClock::new());
1425 let (clips, complete, _) =
1426 pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1427 assert_eq!(clips.len(), 1);
1428 assert_eq!(clips[0].id, "a");
1429 assert!(complete);
1430 }
1431
1432 #[test]
1433 fn api_request_uses_clock_now_unix_for_jwt_expiry() {
1434 use crate::consts::JWT_REFRESH_BUFFER;
1435 use base64::Engine;
1436 let exp = 1_000_000i64;
1437 let payload =
1438 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#));
1439 let jwt_str = format!("hdr.{}.sig", payload);
1440 let token_body = format!(r#"{{"jwt": "{jwt_str}"}}"#);
1441 let client_body = serde_json::json!({
1442 "response": {
1443 "last_active_session_id": "s",
1444 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
1445 }
1446 })
1447 .to_string();
1448
1449 let make_http = || {
1450 ScriptedHttp::new()
1451 .route("/v1/client/sessions/", Reply::json(&token_body))
1452 .route("/v1/client", Reply::json(&client_body))
1453 .route("/api/feed/v3", Reply::json(&feed_body()))
1454 };
1455
1456 let http = make_http();
1458 let auth = ClerkAuth::new("eyJtoken");
1459 pollster::block_on(auth.authenticate(&http)).unwrap();
1460 let client = SunoClient::new(auth, RecordingClock::at(exp - JWT_REFRESH_BUFFER));
1461 let (clips, _, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1462 assert_eq!(clips.len(), 1);
1463 assert_eq!(http.count("/v1/client/sessions/"), 2);
1465
1466 let http2 = make_http();
1468 let auth2 = ClerkAuth::new("eyJtoken");
1469 pollster::block_on(auth2.authenticate(&http2)).unwrap();
1470 let client2 = SunoClient::new(auth2, RecordingClock::at(exp - JWT_REFRESH_BUFFER - 1));
1471 let (clips2, _, _) = pollster::block_on(client2.list_clips(&http2, false, None)).unwrap();
1472 assert_eq!(clips2.len(), 1);
1473 assert_eq!(http2.count("/v1/client/sessions/"), 1);
1475 }
1476
1477 #[test]
1478 fn list_clips_reports_incomplete_when_paging_is_capped() {
1479 let mut rules = auth_rules();
1480 rules.push(Rule::new(
1481 "/api/feed/v3",
1482 200,
1483 serde_json::json!({
1484 "has_more": true,
1485 "next_cursor": "cur1",
1486 "clips": [{
1487 "id": "a", "title": "Song A", "status": "complete",
1488 "audio_url": "https://cdn1.suno.ai/a.mp3",
1489 "metadata": {"type": "gen"}
1490 }]
1491 })
1492 .to_string(),
1493 ));
1494 let http = MockHttp::new(rules);
1495 let client = authed_client(&http);
1496
1497 let (_clips, complete, _) =
1498 pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1499 assert!(!complete);
1500 }
1501
1502 fn auth_rules() -> Vec<Rule> {
1503 let client_body = serde_json::json!({
1504 "response": {
1505 "last_active_session_id": "s",
1506 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
1507 }
1508 })
1509 .to_string();
1510 vec![
1511 Rule::new(
1512 "/v1/client/sessions/",
1513 200,
1514 r#"{"jwt": "a.b.c"}"#.to_string(),
1515 ),
1516 Rule::new("/v1/client", 200, client_body),
1517 ]
1518 }
1519
1520 fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
1521 let auth = ClerkAuth::new("eyJtoken");
1522 pollster::block_on(auth.authenticate(http)).unwrap();
1523 SunoClient::new(auth, RecordingClock::new())
1524 }
1525
1526 #[test]
1527 fn get_billing_info_reads_remaining_credits() {
1528 let mut rules = auth_rules();
1529 rules.push(Rule::new(
1530 BILLING_INFO_PATH,
1531 200,
1532 r#"{"total_credits_left":500,"monthly_limit":1000,"monthly_usage":500}"#.to_string(),
1533 ));
1534 let http = MockHttp::new(rules);
1535 let client = authed_client(&http);
1536
1537 let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
1538 assert_eq!(billing.total_credits_left, Some(500));
1539 assert_eq!(billing.monthly_limit, Some(1000));
1540 assert_eq!(billing.monthly_usage, Some(500));
1541 }
1542
1543 #[test]
1544 fn get_billing_info_tolerates_missing_balance() {
1545 let mut rules = auth_rules();
1546 rules.push(Rule::new(
1547 BILLING_INFO_PATH,
1548 200,
1549 r#"{"monthly_usage":12}"#.to_string(),
1550 ));
1551 let http = MockHttp::new(rules);
1552 let client = authed_client(&http);
1553
1554 let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
1555 assert_eq!(billing.total_credits_left, None);
1556 assert_eq!(billing.monthly_usage, Some(12));
1557 }
1558
1559 const BILLING_FULL: &str = r#"{
1562 "subscription_platform": "stripe",
1563 "is_active": true,
1564 "is_past_due": false,
1565 "credits": 0,
1566 "subscription_type": true,
1567 "subscription_anchor": "REDACTED",
1568 "subscription_id": "REDACTED",
1569 "renews_on": "REDACTED",
1570 "period": "month",
1571 "monthly_usage": 50,
1572 "monthly_limit": 2500,
1573 "credit_packs": [
1574 {
1575 "id": "00000000-0000-4000-8000-000000000001",
1576 "amount": 500,
1577 "price_usd": 4
1578 },
1579 {
1580 "id": "00000000-0000-4000-8000-000000000002",
1581 "amount": 1000,
1582 "price_usd": 8
1583 }
1584 ],
1585 "plan": {
1586 "id": "00000000-0000-4000-8000-000000000005",
1587 "level": 10,
1588 "plan_key": "pro",
1589 "name": "Pro Plan",
1590 "features": "Access to our newest model, v4\n2,500 credits (up to 500 songs), refreshes monthly\nCommercial use rights for songs made while subscribed\nCreate up to 10 songs at once\nEarly access to new features\nPriority creation queue\nAbility to purchase add-on credits",
1591 "monthly_price_usd": 10.0,
1592 "annual_price_usd": 96.0,
1593 "usage_plan_features": [
1594 {
1595 "name": "v4"
1596 },
1597 {
1598 "name": "cover"
1599 },
1600 {
1601 "name": "edit_mode"
1602 },
1603 {
1604 "name": "persona"
1605 },
1606 {
1607 "name": "can_buy_credit_top_ups"
1608 },
1609 {
1610 "name": "commercial_rights"
1611 },
1612 {
1613 "name": "get_stems"
1614 },
1615 {
1616 "name": "generate_song_image"
1617 },
1618 {
1619 "name": "auk"
1620 },
1621 {
1622 "name": "negative_tags"
1623 },
1624 {
1625 "name": "remaster"
1626 },
1627 {
1628 "name": "generate_song_video"
1629 },
1630 {
1631 "name": "long_uploads"
1632 },
1633 {
1634 "name": "convert_audio"
1635 },
1636 {
1637 "name": "create_control_sliders"
1638 },
1639 {
1640 "name": "playlist_condition"
1641 },
1642 {
1643 "name": "tag_upsample"
1644 },
1645 {
1646 "name": "custom_models"
1647 }
1648 ]
1649 },
1650 "models": [
1651 {
1652 "can_use": true,
1653 "max_lengths": {
1654 "title": 100,
1655 "prompt": 5000,
1656 "tags": 1000,
1657 "negative_tags": 1000,
1658 "gpt_description_prompt": 3000
1659 },
1660 "name": "Example Artist 5",
1661 "external_key": "chirp-fenix",
1662 "major_version": 5,
1663 "description": "[description redacted]",
1664 "is_default_free_model": false,
1665 "is_default_model": true,
1666 "badges": [
1667 "pro"
1668 ],
1669 "model_badges": [
1670 {
1671 "display_name": "Example Artist 1",
1672 "light": {
1673 "text_color": "000000",
1674 "background_color": "00000000",
1675 "border_color": "000000"
1676 },
1677 "dark": {
1678 "text_color": "FFFFFF",
1679 "background_color": "00000000",
1680 "border_color": "FFFFFF"
1681 }
1682 }
1683 ],
1684 "style": {
1685 "light": {
1686 "text_color": "FD429C"
1687 },
1688 "dark": {
1689 "text_color": "FD429C"
1690 }
1691 },
1692 "capabilities": [
1693 "all"
1694 ],
1695 "features": [
1696 "create_control_sliders",
1697 "tag_upsample",
1698 "mumble_mode",
1699 "vox_and_voices",
1700 "reuse_styles_lyrics"
1701 ],
1702 "allowed_condition_combinations": [
1703 [
1704 "extend"
1705 ],
1706 [
1707 "cover"
1708 ],
1709 [
1710 "infill"
1711 ],
1712 [
1713 "persona"
1714 ],
1715 [
1716 "persona",
1717 "extend"
1718 ],
1719 [
1720 "persona",
1721 "cover"
1722 ],
1723 [
1724 "playlist"
1725 ],
1726 [
1727 "underpaint"
1728 ],
1729 [
1730 "overpaint"
1731 ],
1732 [
1733 "vox"
1734 ],
1735 [
1736 "vox",
1737 "extend"
1738 ],
1739 [
1740 "vox",
1741 "cover"
1742 ],
1743 [
1744 "vox",
1745 "playlist"
1746 ],
1747 [
1748 "persona",
1749 "infill"
1750 ],
1751 [
1752 "cover",
1753 "infill"
1754 ]
1755 ],
1756 "id": "00000000-0000-4000-8000-000000000006"
1757 }
1758 ],
1759 "plan_price": 10.0,
1760 "plan_currency": "AUD",
1761 "plan_currency_price": 15.0,
1762 "payment_method_type": "card",
1763 "can_upgrade_immediately": true,
1764 "plans": [
1765 {
1766 "id": "00000000-0000-4000-8000-000000000015",
1767 "level": 0,
1768 "plan_key": "free",
1769 "name": "Free Plan",
1770 "features": "50 credits renew daily (10 songs)\nCreate up to 4 songs at once\nNo commercial use\nNo credit top ups\nShared generation queue",
1771 "monthly_price_usd": 0.0,
1772 "annual_price_usd": 0.0,
1773 "usage_plan_features": [
1774 {
1775 "name": "tag_upsample"
1776 }
1777 ],
1778 "prices": []
1779 }
1780 ],
1781 "accessible_features": [
1782 {
1783 "name": "v4"
1784 },
1785 {
1786 "name": "cover"
1787 },
1788 {
1789 "name": "edit_mode"
1790 },
1791 {
1792 "name": "persona"
1793 },
1794 {
1795 "name": "can_buy_credit_top_ups"
1796 },
1797 {
1798 "name": "commercial_rights"
1799 },
1800 {
1801 "name": "get_stems"
1802 },
1803 {
1804 "name": "generate_song_image"
1805 },
1806 {
1807 "name": "auk"
1808 },
1809 {
1810 "name": "negative_tags"
1811 },
1812 {
1813 "name": "remaster"
1814 },
1815 {
1816 "name": "generate_song_video"
1817 },
1818 {
1819 "name": "long_uploads"
1820 },
1821 {
1822 "name": "convert_audio"
1823 },
1824 {
1825 "name": "create_control_sliders"
1826 },
1827 {
1828 "name": "playlist_condition"
1829 },
1830 {
1831 "name": "tag_upsample"
1832 },
1833 {
1834 "name": "custom_models"
1835 }
1836 ],
1837 "revcat_subscriptions_offering_id": "REDACTED",
1838 "total_credits_left": 2450,
1839 "free_persona_clips_remaining": 0,
1840 "free_cover_clips_remaining": 0,
1841 "free_remasters_remaining": 0,
1842 "free_mobile_remasters_remaining": 0,
1843 "free_mobile_v4_gens_remaining": 0,
1844 "free_web_v4_gens_remaining": 0,
1845 "free_vox_gens_remaining": 0,
1846 "has_been_subscriber_before": true,
1847 "has_valid_school_email": false,
1848 "has_been_student_subscriber_before": false,
1849 "day0_boost": -1,
1850 "promotions": [],
1851 "audio_upload_limits": {
1852 "min": 6,
1853 "max": 1800
1854 },
1855 "voice_upload_limits": {
1856 "min": 10,
1857 "max": 900
1858 },
1859 "voice_record_limits": {
1860 "min": 10,
1861 "max": 240
1862 },
1863 "period_end": "REDACTED",
1864 "remaster_model_types": [
1865 {
1866 "name": "Example Artist 5",
1867 "external_key": "chirp-flounder",
1868 "is_default_model": true,
1869 "can_use": false
1870 },
1871 {
1872 "name": "Example Artist 2",
1873 "external_key": "chirp-carp",
1874 "is_default_model": false,
1875 "can_use": false
1876 },
1877 {
1878 "name": "v4.5+",
1879 "external_key": "chirp-bass",
1880 "is_default_model": false,
1881 "can_use": false
1882 }
1883 ],
1884 "is_pause_scheduled": false,
1885 "is_paused": false,
1886 "is_gifted": false
1887}"#;
1888
1889 #[test]
1890 fn parse_billing_info_reads_full_real_body() {
1891 let billing = parse_billing_info(BILLING_FULL.as_bytes()).unwrap();
1892 assert_eq!(billing.total_credits_left, Some(2450));
1893 assert_eq!(billing.monthly_limit, Some(2500));
1894 assert_eq!(billing.monthly_usage, Some(50));
1895 assert_eq!(billing.credits, Some(0));
1896 assert_eq!(billing.period.as_deref(), Some("month"));
1897 assert_eq!(billing.is_active, Some(true));
1898 assert_eq!(billing.is_paused, Some(false));
1899 assert_eq!(billing.is_past_due, Some(false));
1900 assert_eq!(billing.is_gifted, Some(false));
1901 assert_eq!(billing.subscription_platform.as_deref(), Some("stripe"));
1902 assert_eq!(billing.plan_key.as_deref(), Some("pro"));
1903 assert_eq!(billing.plan_name.as_deref(), Some("Pro Plan"));
1904 assert_eq!(billing.plan_level, Some(10));
1905 assert!(billing.can_get_stems());
1906 assert!(billing.can_convert_audio());
1907 assert!(billing.has_feature("custom_models"));
1908 }
1909
1910 #[test]
1911 fn json_i64_reads_string_encoded_integer() {
1912 let billing = parse_billing_info(br#"{"total_credits_left":"2450"}"#).unwrap();
1913 assert_eq!(billing.total_credits_left, Some(2450));
1914 }
1915
1916 #[test]
1917 fn json_i64_reads_integral_float() {
1918 let billing = parse_billing_info(br#"{"total_credits_left":2450.0}"#).unwrap();
1919 assert_eq!(billing.total_credits_left, Some(2450));
1920 }
1921
1922 #[test]
1923 fn json_i64_reads_negative_sentinel() {
1924 let billing = parse_billing_info(br#"{"total_credits_left":-1}"#).unwrap();
1925 assert_eq!(billing.total_credits_left, Some(-1));
1926 }
1927
1928 #[test]
1929 fn json_i64_rejects_non_integral_float_but_object_still_parses() {
1930 let billing =
1931 parse_billing_info(br#"{"total_credits_left":2450.5,"period":"month"}"#).unwrap();
1932 assert_eq!(billing.total_credits_left, None);
1933 assert_eq!(billing.period.as_deref(), Some("month"));
1934 }
1935
1936 #[test]
1937 fn str_to_i64_handles_encodings_and_junk() {
1938 assert_eq!(str_to_i64("2450"), Some(2450));
1939 assert_eq!(str_to_i64("2450.0"), Some(2450));
1940 assert_eq!(str_to_i64("-1"), Some(-1));
1941 assert_eq!(str_to_i64("2450.5"), None);
1942 assert_eq!(str_to_i64(".5"), None);
1943 assert_eq!(str_to_i64("nope"), None);
1944 assert_eq!(str_to_i64("99999999999999999999999"), None);
1945 }
1946
1947 #[test]
1948 fn json_i64_rejects_overflow() {
1949 let billing =
1950 parse_billing_info(br#"{"total_credits_left":99999999999999999999999}"#).unwrap();
1951 assert_eq!(billing.total_credits_left, None);
1952 }
1953
1954 #[test]
1955 fn json_i64_covers_i64_and_float_boundaries() {
1956 assert_eq!(json_i64(&serde_json::json!(i64::MAX)), Some(i64::MAX));
1958 assert_eq!(json_i64(&serde_json::json!(i64::MIN)), Some(i64::MIN));
1959 assert_eq!(
1961 json_i64(&serde_json::json!(9_223_372_036_854_775_808_u64)),
1962 None
1963 );
1964 assert_eq!(f64_to_i64(i64::MAX as f64), None);
1966 assert_eq!(f64_to_i64(i64::MIN as f64), None);
1967 assert_eq!(f64_to_i64(2450.5), None);
1968 assert_eq!(f64_to_i64(f64::NAN), None);
1969 assert_eq!(f64_to_i64(f64::INFINITY), None);
1970 }
1971
1972 #[test]
1973 fn f64_to_i64_rejects_values_below_i64_min() {
1974 let below_min: f64 = "-9223372036854775809".parse().unwrap();
1976 assert_eq!(f64_to_i64(below_min), None);
1977 assert_eq!(str_to_i64("-9223372036854775809"), None);
1979 assert_eq!(json_i64(&serde_json::json!("-9223372036854775809")), None);
1980 }
1981
1982 #[test]
1983 fn f64_to_i64_trusts_only_the_safe_integer_range() {
1984 assert_eq!(
1986 f64_to_i64(9_007_199_254_740_991.0),
1987 Some(9_007_199_254_740_991)
1988 );
1989 let rounded: f64 = "9007199254740993".parse().unwrap();
1992 assert_eq!(rounded, 9_007_199_254_740_992.0);
1993 assert_eq!(f64_to_i64(rounded), None);
1994 }
1995
1996 #[test]
1997 fn parse_billing_info_defaults_missing_fields() {
1998 let billing = parse_billing_info(br#"{"monthly_usage":12}"#).unwrap();
1999 assert_eq!(billing.total_credits_left, None);
2000 assert_eq!(billing.monthly_usage, Some(12));
2001 assert_eq!(billing.plan_key, None);
2002 assert!(billing.features.is_empty());
2003 assert!(!billing.can_get_stems());
2004 }
2005
2006 #[test]
2007 fn from_billing_json_ignores_surprising_types() {
2008 let value = serde_json::json!({
2011 "subscription_type": true,
2012 "total_credits_left": {"unexpected": "object"},
2013 "is_active": "yes",
2014 });
2015 let billing = from_billing_json(&value);
2016 assert_eq!(billing.total_credits_left, None);
2017 assert_eq!(billing.is_active, None);
2018 }
2019
2020 #[test]
2021 fn parse_billing_info_treats_non_object_json_as_default() {
2022 for body in [
2023 b"null".as_slice(),
2024 b"[]".as_slice(),
2025 br#""hello""#.as_slice(),
2026 ] {
2027 assert_eq!(parse_billing_info(body).unwrap(), BillingInfo::default());
2028 }
2029 }
2030
2031 #[test]
2032 fn parse_billing_info_rejects_non_json_bytes() {
2033 let err = parse_billing_info(b"nope").unwrap_err();
2034 assert!(err.to_string().contains("invalid billing JSON"));
2035 }
2036
2037 #[test]
2038 fn from_billing_json_unions_feature_sources() {
2039 let accessible_only = serde_json::json!({
2040 "accessible_features": [{"name": "get_stems"}],
2041 });
2042 assert!(from_billing_json(&accessible_only).can_get_stems());
2043
2044 let plan_only = serde_json::json!({
2045 "plan": {"usage_plan_features": [{"name": "convert_audio"}]},
2046 });
2047 assert!(from_billing_json(&plan_only).can_convert_audio());
2048
2049 let both = serde_json::json!({
2050 "accessible_features": [{"name": "get_stems"}, {"name": ""}, {"other": "x"}],
2051 "plan": {"usage_plan_features": [{"name": "convert_audio"}]},
2052 });
2053 let billing = from_billing_json(&both);
2054 assert!(billing.can_get_stems());
2055 assert!(billing.can_convert_audio());
2056 assert_eq!(billing.features.len(), 2);
2058 }
2059
2060 #[test]
2061 fn aligned_lyrics_reads_words_and_lines() {
2062 let mut rules = auth_rules();
2063 let body = serde_json::json!({
2064 "aligned_words": [
2065 {"word": "hi", "success": true, "start_s": 0.5, "end_s": 0.9, "p_align": 0.99}
2066 ],
2067 "aligned_lyrics": [
2068 {"text": "hi", "start_s": 0.5, "end_s": 0.9, "section": "Verse 1",
2069 "words": [{"text": "hi", "start_s": 0.5, "end_s": 0.9}]}
2070 ],
2071 "hoot_cer": 0.2, "is_streamed": false
2072 })
2073 .to_string();
2074 rules.push(Rule::new("/aligned_lyrics/v2/", 200, body));
2075 let http = MockHttp::new(rules);
2076 let client = authed_client(&http);
2077
2078 let aligned = pollster::block_on(client.aligned_lyrics(&http, "clip-1")).unwrap();
2079 assert_eq!(aligned.words.len(), 1);
2080 assert_eq!(aligned.lines.len(), 1);
2081 assert_eq!(aligned.lines[0].section, "Verse 1");
2082 assert!(!aligned.is_empty());
2083 }
2084
2085 #[test]
2086 fn aligned_lyrics_empty_arrays_map_to_empty() {
2087 let mut rules = auth_rules();
2088 rules.push(Rule::new(
2089 "/aligned_lyrics/v2/",
2090 200,
2091 r#"{"aligned_words":[],"aligned_lyrics":[],"hoot_cer":1.0}"#.to_string(),
2092 ));
2093 let http = MockHttp::new(rules);
2094 let client = authed_client(&http);
2095
2096 let aligned = pollster::block_on(client.aligned_lyrics(&http, "instr")).unwrap();
2097 assert!(aligned.is_empty());
2098 }
2099
2100 #[test]
2101 fn aligned_lyrics_maps_404_to_empty() {
2102 let mut rules = auth_rules();
2103 rules.push(Rule::new(
2104 "/aligned_lyrics/v2/",
2105 404,
2106 "not found".to_string(),
2107 ));
2108 let http = MockHttp::new(rules);
2109 let client = authed_client(&http);
2110
2111 let aligned = pollster::block_on(client.aligned_lyrics(&http, "missing")).unwrap();
2112 assert!(aligned.is_empty());
2113 }
2114
2115 fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
2116 let auth = ClerkAuth::new("eyJtoken");
2117 pollster::block_on(auth.authenticate(http)).unwrap();
2118 SunoClient::new(auth, clock)
2119 }
2120
2121 fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
2122 let mut page = serde_json::json!({
2123 "has_more": next_cursor.is_some(),
2124 "clips": [{
2125 "id": id, "title": "Song", "status": "complete",
2126 "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
2127 "metadata": {"type": "gen"}
2128 }]
2129 });
2130 if let Some(cursor) = next_cursor {
2131 page["next_cursor"] = serde_json::json!(cursor);
2132 }
2133 page.to_string()
2134 }
2135
2136 #[test]
2137 fn list_clips_retries_a_rate_limited_page() {
2138 let http = ScriptedHttp::new().with_auth().route_seq(
2139 "/api/feed/v3",
2140 vec![Reply::status(429), Reply::json(&feed_body())],
2141 );
2142 let clock = RecordingClock::new();
2143 let client = scripted_client(&http, clock.clone());
2144
2145 let (clips, complete, _) =
2146 pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2147 assert_eq!(clips.len(), 1);
2148 assert!(complete);
2149 assert_eq!(http.count("/api/feed/v3"), 2);
2151 assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
2152 }
2153
2154 #[test]
2155 fn list_clips_honours_retry_after_on_a_throttled_page() {
2156 let http = ScriptedHttp::new().with_auth().route_seq(
2157 "/api/feed/v3",
2158 vec![
2159 Reply::status(429).with_retry_after(7),
2160 Reply::json(&feed_body()),
2161 ],
2162 );
2163 let clock = RecordingClock::new();
2164 let client = scripted_client(&http, clock.clone());
2165
2166 let (clips, _complete, _) =
2167 pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2168 assert_eq!(clips.len(), 1);
2169 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
2171 }
2172
2173 #[test]
2174 fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
2175 let http = ScriptedHttp::new().with_auth().route_seq(
2177 "/api/feed/v3",
2178 vec![
2179 Reply::json(&one_clip_page("a", Some("cur1"))),
2180 Reply::status(429),
2181 Reply::json(&one_clip_page("b", None)),
2182 ],
2183 );
2184 let clock = RecordingClock::new();
2185 let client = scripted_client(&http, clock.clone());
2186
2187 let (clips, complete, _) =
2188 pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2189 assert!(complete);
2190 assert_eq!(clips.len(), 2);
2191 let bodies = http.bodies();
2192 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
2193 assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
2194 let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
2197 let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
2198 assert_eq!(retried["cursor"], "cur1");
2199 assert_eq!(after_retry["cursor"], "cur1");
2200 }
2201
2202 #[test]
2203 fn list_clips_threads_the_cursor_across_pages() {
2204 let http = ScriptedHttp::new().with_auth().route_seq(
2205 "/api/feed/v3",
2206 vec![
2207 Reply::json(&one_clip_page("a", Some("cur1"))),
2208 Reply::json(&one_clip_page("b", None)),
2209 ],
2210 );
2211 let clock = RecordingClock::new();
2212 let client = scripted_client(&http, clock.clone());
2213
2214 let (clips, complete, _) =
2215 pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2216 assert!(complete);
2217 assert_eq!(clips.len(), 2);
2218 let bodies = http.bodies();
2219 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
2220 assert_eq!(feed_bodies.len(), 2);
2221 let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
2222 let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
2223 assert!(page1.get("cursor").is_none());
2225 assert_eq!(page2["cursor"], "cur1");
2226 }
2227
2228 #[test]
2229 fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
2230 let page = serde_json::json!({
2233 "has_more": true,
2234 "clips": [{
2235 "id": "a", "title": "Song", "status": "complete",
2236 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
2237 }]
2238 })
2239 .to_string();
2240 let http = ScriptedHttp::new()
2241 .with_auth()
2242 .route("/api/feed/v3", Reply::json(&page));
2243 let clock = RecordingClock::new();
2244 let client = scripted_client(&http, clock.clone());
2245
2246 let (clips, complete, _) =
2247 pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2248 assert!(!complete);
2249 assert_eq!(clips.len(), 1);
2250 assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
2251 }
2252
2253 #[test]
2254 fn list_clips_is_incomplete_when_has_more_is_missing() {
2255 let page = serde_json::json!({
2257 "clips": [{
2258 "id": "a", "title": "Song", "status": "complete",
2259 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
2260 }]
2261 })
2262 .to_string();
2263 let http = ScriptedHttp::new()
2264 .with_auth()
2265 .route("/api/feed/v3", Reply::json(&page));
2266 let clock = RecordingClock::new();
2267 let client = scripted_client(&http, clock.clone());
2268
2269 let (clips, complete, _) =
2270 pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2271 assert!(!complete);
2272 assert_eq!(clips.len(), 1);
2273 assert_eq!(http.count("/api/feed/v3"), 1);
2274 }
2275
2276 #[test]
2277 fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
2278 let http = ScriptedHttp::new().with_auth().route_seq(
2279 "/api/feed/v3",
2280 vec![
2281 Reply::json(&one_clip_page("a", Some("cur1"))),
2282 Reply::status(500),
2283 ],
2284 );
2285 let clock = RecordingClock::new();
2286 let client = scripted_client(&http, clock.clone());
2287
2288 let result = pollster::block_on(client.list_clips(&http, false, None));
2289 assert!(matches!(result, Err(Error::Api(_))));
2290 }
2291
2292 #[test]
2293 fn list_clips_is_complete_on_an_empty_drained_feed() {
2294 let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
2297 let http = ScriptedHttp::new()
2298 .with_auth()
2299 .route("/api/feed/v3", Reply::json(&page));
2300 let clock = RecordingClock::new();
2301 let client = scripted_client(&http, clock.clone());
2302
2303 let (clips, complete, _) =
2304 pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2305 assert!(complete);
2306 assert!(clips.is_empty());
2307 }
2308
2309 #[test]
2310 fn list_clips_flags_filter_loss_on_a_drained_feed() {
2311 let http = ScriptedHttp::new()
2316 .with_auth()
2317 .route("/api/feed/v3", Reply::json(&feed_body()));
2318 let clock = RecordingClock::new();
2319 let client = scripted_client(&http, clock.clone());
2320
2321 let (clips, complete, any_filtered) =
2322 pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2323 assert!(complete);
2324 assert!(any_filtered);
2325 assert_eq!(clips.len(), 1);
2326 }
2327
2328 #[test]
2329 fn list_clips_ors_filter_loss_across_pages() {
2330 let page2 = serde_json::json!({
2333 "has_more": false,
2334 "clips": [
2335 {"id": "e", "status": "complete", "metadata": {"type": "gen"}},
2336 {"id": "f", "status": "streaming", "metadata": {}}
2337 ]
2338 })
2339 .to_string();
2340 let http = ScriptedHttp::new().with_auth().route_seq(
2341 "/api/feed/v3",
2342 vec![
2343 Reply::json(&one_clip_page("a", Some("cur1"))),
2344 Reply::json(&page2),
2345 ],
2346 );
2347 let clock = RecordingClock::new();
2348 let client = scripted_client(&http, clock.clone());
2349
2350 let (clips, complete, any_filtered) =
2351 pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2352 assert!(complete);
2353 assert!(any_filtered);
2354 assert_eq!(clips.len(), 2);
2356 }
2357
2358 #[test]
2359 fn list_clips_liked_scope_sends_the_liked_filter() {
2360 let http = ScriptedHttp::new()
2361 .with_auth()
2362 .route("/api/feed/v3", Reply::json(&feed_body()));
2363 let clock = RecordingClock::new();
2364 let client = scripted_client(&http, clock.clone());
2365
2366 let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
2367 let bodies = http.bodies();
2368 let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
2369 let value: Value = serde_json::from_str(feed_body).unwrap();
2370 assert_eq!(value["filters"]["liked"], "True");
2371 assert_eq!(value["filters"]["trashed"], "False");
2372 }
2373
2374 #[test]
2375 fn list_clips_does_not_pace_an_unthrottled_walk() {
2376 let http = ScriptedHttp::new().with_auth().route_seq(
2377 "/api/feed/v3",
2378 vec![
2379 Reply::json(&one_clip_page("a", Some("cur1"))),
2380 Reply::json(&one_clip_page("e", None)),
2381 ],
2382 );
2383 let clock = RecordingClock::new();
2384 let client = scripted_client(&http, clock.clone());
2385
2386 let (clips, complete, _) =
2387 pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2388 assert!(complete);
2389 assert_eq!(clips.len(), 2);
2390 assert_eq!(http.count("/api/feed/v3"), 2);
2391 assert!(clock.sleeps().is_empty());
2393 }
2394
2395 #[test]
2396 fn list_clips_slows_its_pace_after_a_throttled_page() {
2397 let http = ScriptedHttp::new().with_auth().route_seq(
2398 "/api/feed/v3",
2399 vec![
2400 Reply::status(429),
2401 Reply::json(&one_clip_page("a", Some("cur1"))),
2402 Reply::json(&one_clip_page("e", None)),
2403 ],
2404 );
2405 let clock = RecordingClock::new();
2406 let client = scripted_client(&http, clock.clone());
2407
2408 let (clips, complete, _) =
2409 pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2410 assert!(complete);
2411 assert_eq!(clips.len(), 2);
2412 assert_eq!(
2415 clock.sleeps(),
2416 vec![Duration::from_secs(5), Duration::from_secs(1)]
2417 );
2418 }
2419
2420 #[test]
2421 fn list_clips_gives_up_after_max_retries() {
2422 let http = ScriptedHttp::new()
2423 .with_auth()
2424 .route("/api/feed/v3", Reply::status(429));
2425 let clock = RecordingClock::new();
2426 let client = scripted_client(&http, clock.clone());
2427
2428 let result = pollster::block_on(client.list_clips(&http, false, None));
2429 assert!(matches!(result, Err(Error::RateLimited { .. })));
2430 let budget = crate::consts::API_MAX_RETRIES as usize;
2431 assert_eq!(clock.sleeps().len(), budget);
2432 assert_eq!(http.count("/api/feed/v3"), budget + 1);
2433 }
2434
2435 #[test]
2436 fn parse_clip_accepts_bare_and_wrapped_shapes() {
2437 let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
2438 assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
2439
2440 let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
2441 assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
2442
2443 let missing = serde_json::json!({"detail": "not found"}).to_string();
2444 assert!(parse_clip(missing.as_bytes()).is_none());
2445 }
2446
2447 #[test]
2448 fn get_clip_uses_the_dedicated_endpoint() {
2449 let clip_body = serde_json::json!({
2450 "id": "z", "title": "Zed", "status": "complete",
2451 "audio_url": "https://cdn1.suno.ai/z.mp3",
2452 "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
2453 })
2454 .to_string();
2455 let mut rules = auth_rules();
2456 rules.push(Rule::new("/api/clip/", 200, clip_body));
2457 let http = MockHttp::new(rules);
2458 let client = authed_client(&http);
2459
2460 let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
2461 assert_eq!(clip.id, "z");
2462 assert_eq!(clip.title, "Zed");
2463 assert_eq!(clip.tags, "jazz");
2464 }
2465
2466 #[test]
2467 fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
2468 let mut rules = auth_rules();
2469 rules.push(Rule::new(
2470 "/api/clip/",
2471 404,
2472 r#"{"detail": "not found"}"#.to_string(),
2473 ));
2474 rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
2475 let http = MockHttp::new(rules);
2476 let client = authed_client(&http);
2477
2478 let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
2479 assert_eq!(clip.id, "a");
2480 assert_eq!(clip.tags, "rock");
2481 }
2482
2483 #[test]
2484 fn request_wav_accepts_a_2xx_status() {
2485 let mut rules = auth_rules();
2486 rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
2487 let http = MockHttp::new(rules);
2488 let client = authed_client(&http);
2489
2490 assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
2491 }
2492
2493 #[test]
2494 fn wav_url_reads_the_ready_url() {
2495 let mut rules = auth_rules();
2496 rules.push(Rule::new(
2497 "/wav_file/",
2498 200,
2499 r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
2500 ));
2501 let http = MockHttp::new(rules);
2502 let client = authed_client(&http);
2503
2504 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
2505 assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
2506 }
2507
2508 #[test]
2509 fn wav_url_is_none_until_the_render_is_ready() {
2510 let mut rules = auth_rules();
2511 rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
2512 let http = MockHttp::new(rules);
2513 let client = authed_client(&http);
2514
2515 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
2516 assert_eq!(url, None);
2517 }
2518
2519 #[test]
2520 fn wav_url_404_maps_to_none() {
2521 let mut rules = auth_rules();
2525 rules.push(Rule::new(
2526 "/wav_file/",
2527 404,
2528 r#"{"detail": "Not found."}"#.to_string(),
2529 ));
2530 let http = MockHttp::new(rules);
2531 let client = authed_client(&http);
2532
2533 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
2534 assert_eq!(url, None);
2535 }
2536
2537 #[test]
2538 fn get_clips_by_ids_keeps_infill_and_upload_ancestors() {
2539 let p1 = serde_json::json!({
2543 "id": "p1", "title": "Infill Ancestor", "status": "complete",
2544 "metadata": {"type": "gen", "task": "infill"}
2545 })
2546 .to_string();
2547 let p2 = serde_json::json!({
2548 "id": "p2", "title": "Uploaded Root", "status": "complete",
2549 "metadata": {"type": "upload"}
2550 })
2551 .to_string();
2552 let batch = format!(r#"{{"clips":[{p1},{p2}]}}"#);
2553 let mut rules = auth_rules();
2554 rules.push(Rule::new("get_songs_by_ids", 200, batch));
2555 rules.push(Rule::new("/api/clip/p1", 200, p1));
2556 rules.push(Rule::new("/api/clip/p2", 200, p2));
2557 let http = MockHttp::new(rules);
2558 let client = authed_client(&http);
2559
2560 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"], 4)).unwrap();
2561 assert_eq!(
2562 clips.len(),
2563 2,
2564 "infill and upload ancestors must not be filtered"
2565 );
2566 assert_eq!(clips[0].id, "p1");
2567 assert_eq!(clips[1].id, "p2");
2568 }
2569
2570 #[test]
2571 fn get_clips_by_ids_returns_a_trashed_clip() {
2572 let trashed = serde_json::json!({
2575 "id": "t1", "title": "Trashed Ancestor", "status": "complete",
2576 "is_trashed": true, "metadata": {"type": "gen"}
2577 })
2578 .to_string();
2579 let batch = format!(r#"{{"clips":[{trashed}]}}"#);
2580 let mut rules = auth_rules();
2581 rules.push(Rule::new("get_songs_by_ids", 200, batch));
2582 rules.push(Rule::new("/api/clip/t1", 200, trashed));
2583 let http = MockHttp::new(rules);
2584 let client = authed_client(&http);
2585
2586 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"], 4)).unwrap();
2587 assert_eq!(clips.len(), 1);
2588 assert_eq!(clips[0].id, "t1");
2589 assert!(clips[0].is_trashed);
2590 }
2591
2592 #[test]
2593 fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
2594 let only = serde_json::json!({
2595 "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
2596 })
2597 .to_string();
2598 let batch = format!(r#"{{"clips":[{only}]}}"#);
2601 let http = ScriptedHttp::new()
2602 .with_auth()
2603 .route("get_songs_by_ids", Reply::json(&batch))
2604 .route("/api/clip/gone", Reply::status(404));
2605 let client = scripted_client(&http, RecordingClock::new());
2606
2607 let clips =
2608 pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"], 4))
2609 .unwrap();
2610 assert_eq!(clips.len(), 1, "the 404 id is skipped");
2611 assert_eq!(clips[0].id, "only");
2612 assert_eq!(
2615 http.count("get_songs_by_ids"),
2616 1,
2617 "one batch call for both ids"
2618 );
2619 assert_eq!(http.count("/api/clip/only"), 0);
2620 assert_eq!(http.count("/api/clip/gone"), 1);
2621 }
2622
2623 #[test]
2624 fn get_clips_by_ids_matches_serial_results_and_keeps_order_when_concurrent() {
2625 let a = serde_json::json!({
2629 "id": "a", "title": "A", "status": "complete", "metadata": {"type": "gen"}
2630 })
2631 .to_string();
2632 let b = serde_json::json!({
2633 "id": "b", "title": "B", "status": "complete", "metadata": {"type": "gen"}
2634 })
2635 .to_string();
2636 let c = serde_json::json!({
2637 "id": "c", "title": "C", "status": "complete", "metadata": {"type": "gen"}
2638 })
2639 .to_string();
2640 let http = ScriptedHttp::new()
2641 .with_auth()
2642 .route("/api/clip/a", Reply::json(&a))
2643 .route("/api/clip/b", Reply::json(&b))
2644 .route("/api/clip/c", Reply::json(&c));
2645 let client = scripted_client(&http, RecordingClock::new());
2646 let ids = ["b", "a", "c", "a"];
2647
2648 let serial = pollster::block_on(client.get_clips_by_ids(&http, &ids, 1)).unwrap();
2649 let concurrent = pollster::block_on(client.get_clips_by_ids(&http, &ids, 4)).unwrap();
2650
2651 let serial_ids: Vec<&str> = serial.iter().map(|clip| clip.id.as_str()).collect();
2652 let concurrent_ids: Vec<&str> = concurrent.iter().map(|clip| clip.id.as_str()).collect();
2653 assert_eq!(serial_ids, vec!["b", "a", "c"]);
2654 assert_eq!(concurrent_ids, serial_ids);
2655 }
2656
2657 fn clip_body(id: &str) -> String {
2659 format!(r#"{{"id":"{id}","title":"T","status":"complete","metadata":{{"type":"gen"}}}}"#)
2660 }
2661
2662 #[test]
2663 fn get_songs_by_ids_maps_the_batch_body_matched_by_id_in_input_order() {
2664 let batch = format!(
2667 r#"{{"clips":[{},{},{}]}}"#,
2668 clip_body("c"),
2669 clip_body("a"),
2670 clip_body("b")
2671 );
2672 let http = ScriptedHttp::new()
2673 .with_auth()
2674 .route("get_songs_by_ids", Reply::json(&batch));
2675 let client = scripted_client(&http, RecordingClock::new());
2676
2677 let clips =
2678 pollster::block_on(client.get_songs_by_ids(&http, &["a", "b", "c", "a"])).unwrap();
2679 let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2680 assert_eq!(ids, vec!["a", "b", "c"], "input order, not response order");
2681 assert_eq!(http.count("get_songs_by_ids"), 1, "one chunk, one request");
2682 }
2683
2684 #[test]
2685 fn get_songs_by_ids_drops_clips_that_were_not_requested() {
2686 let batch = format!(r#"{{"clips":[{},{}]}}"#, clip_body("a"), clip_body("x"));
2688 let http = ScriptedHttp::new()
2689 .with_auth()
2690 .route("get_songs_by_ids", Reply::json(&batch));
2691 let client = scripted_client(&http, RecordingClock::new());
2692
2693 let clips = pollster::block_on(client.get_songs_by_ids(&http, &["a"])).unwrap();
2694 let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2695 assert_eq!(ids, vec!["a"], "an unrequested id is dropped");
2696 }
2697
2698 #[test]
2699 fn get_songs_by_ids_chunks_ids_beyond_the_chunk_size() {
2700 let ids: Vec<String> = (0..21).map(|i| format!("id-{i:02}")).collect();
2703 let body = |slice: &[String]| {
2704 let clips: Vec<String> = slice.iter().map(|id| clip_body(id)).collect();
2705 format!(r#"{{"clips":[{}]}}"#, clips.join(","))
2706 };
2707 let http = ScriptedHttp::new().with_auth().route_seq(
2708 "get_songs_by_ids",
2709 vec![
2710 Reply::json(&body(&ids[..20])),
2711 Reply::json(&body(&ids[20..])),
2712 ],
2713 );
2714 let client = scripted_client(&http, RecordingClock::new());
2715 let refs: Vec<&str> = ids.iter().map(String::as_str).collect();
2716
2717 let clips = pollster::block_on(client.get_songs_by_ids(&http, &refs)).unwrap();
2718 let got: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2719 assert_eq!(got, refs, "all 21 ids returned in input order");
2720 assert_eq!(
2721 http.count("get_songs_by_ids"),
2722 2,
2723 "two chunks -> two requests"
2724 );
2725 let batch_calls: Vec<String> = http
2726 .calls()
2727 .into_iter()
2728 .filter(|url| url.contains("get_songs_by_ids"))
2729 .collect();
2730 assert_eq!(
2731 batch_calls[0].matches("ids=").count(),
2732 20,
2733 "first chunk of 20"
2734 );
2735 assert_eq!(
2736 batch_calls[1].matches("ids=").count(),
2737 1,
2738 "second chunk of 1"
2739 );
2740 }
2741
2742 #[test]
2743 fn get_clips_by_ids_batch_first_does_not_fetch_per_id_when_batch_is_complete() {
2744 let batch = format!(r#"{{"clips":[{},{}]}}"#, clip_body("a"), clip_body("b"));
2746 let http = ScriptedHttp::new()
2747 .with_auth()
2748 .route("get_songs_by_ids", Reply::json(&batch))
2749 .route("/api/clip/a", Reply::json(&clip_body("a")))
2750 .route("/api/clip/b", Reply::json(&clip_body("b")));
2751 let client = scripted_client(&http, RecordingClock::new());
2752
2753 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4)).unwrap();
2754 let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2755 assert_eq!(ids, vec!["a", "b"]);
2756 assert_eq!(http.count("get_songs_by_ids"), 1);
2757 assert_eq!(
2758 http.count("/api/clip/"),
2759 0,
2760 "a complete batch needs no per-id fallback"
2761 );
2762 }
2763
2764 #[test]
2765 fn get_clips_by_ids_fills_ids_the_batch_omits_via_per_id() {
2766 let batch = format!(r#"{{"clips":[{}]}}"#, clip_body("a"));
2768 let http = ScriptedHttp::new()
2769 .with_auth()
2770 .route("get_songs_by_ids", Reply::json(&batch))
2771 .route("/api/clip/b", Reply::json(&clip_body("b")));
2772 let client = scripted_client(&http, RecordingClock::new());
2773
2774 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4)).unwrap();
2775 let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2776 assert_eq!(ids, vec!["a", "b"], "omitted id is filled, order preserved");
2777 assert_eq!(http.count("/api/clip/a"), 0, "a came from the batch");
2778 assert_eq!(http.count("/api/clip/b"), 1, "b was filled per-id");
2779 }
2780
2781 #[test]
2782 fn get_clips_by_ids_falls_back_to_per_id_on_a_malformed_batch_body() {
2783 let http = ScriptedHttp::new()
2786 .with_auth()
2787 .route("get_songs_by_ids", Reply::json("not-json{"))
2788 .route("/api/clip/a", Reply::json(&clip_body("a")))
2789 .route("/api/clip/b", Reply::json(&clip_body("b")));
2790 let client = scripted_client(&http, RecordingClock::new());
2791
2792 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4)).unwrap();
2793 let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2794 assert_eq!(ids, vec!["a", "b"]);
2795 assert_eq!(http.count("/api/clip/a"), 1);
2796 assert_eq!(http.count("/api/clip/b"), 1);
2797 }
2798
2799 #[test]
2800 fn get_clips_by_ids_propagates_a_batch_rate_limit_without_per_id_fan_out() {
2801 let http = ScriptedHttp::new()
2804 .with_auth()
2805 .route("get_songs_by_ids", Reply::status(429))
2806 .route("/api/clip/a", Reply::json(&clip_body("a")))
2807 .route("/api/clip/b", Reply::json(&clip_body("b")));
2808 let client = scripted_client(&http, RecordingClock::new());
2809
2810 let result = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4));
2811 assert!(
2812 matches!(result, Err(Error::RateLimited { .. })),
2813 "an exhausted 429 propagates"
2814 );
2815 assert_eq!(
2816 http.count("/api/clip/"),
2817 0,
2818 "no per-id fan-out on rate-limit exhaustion"
2819 );
2820 }
2821
2822 #[test]
2823 fn concurrent_reads_share_aggregate_pacing_after_first_rate_limit() {
2824 const EXPECTED_SPAN: Duration = Duration::from_secs(4);
2829 const TOLERANCE: Duration = Duration::from_millis(50);
2830 let ids = ["a", "b", "c", "d"];
2831 let a =
2832 serde_json::json!({"id":"a","title":"A","status":"complete","metadata":{"type":"gen"}})
2833 .to_string();
2834 let b =
2835 serde_json::json!({"id":"b","title":"B","status":"complete","metadata":{"type":"gen"}})
2836 .to_string();
2837 let c =
2838 serde_json::json!({"id":"c","title":"C","status":"complete","metadata":{"type":"gen"}})
2839 .to_string();
2840 let d =
2841 serde_json::json!({"id":"d","title":"D","status":"complete","metadata":{"type":"gen"}})
2842 .to_string();
2843 let http = ScriptedHttp::new()
2844 .with_auth()
2845 .route_seq(
2846 "/api/feed/v3",
2847 vec![
2848 Reply::status(429),
2849 Reply::json(&one_clip_page("seed", None)),
2850 ],
2851 )
2852 .route("get_songs_by_ids", Reply::json(r#"{"clips":[]}"#))
2853 .route("/api/clip/a", Reply::json(&a))
2854 .route("/api/clip/b", Reply::json(&b))
2855 .route("/api/clip/c", Reply::json(&c))
2856 .route("/api/clip/d", Reply::json(&d));
2857 let clock = RecordingClock::new();
2858 let client = scripted_client(&http, clock.clone());
2859 pollster::block_on(client.list_clips(&http, false, Some(1))).unwrap();
2860 let before = clock.sleeps().len();
2861
2862 let clips = pollster::block_on(client.get_clips_by_ids(&http, &ids, ids.len())).unwrap();
2863 assert_eq!(clips.len(), ids.len());
2864 let sleeps = clock.sleeps();
2865 let paced = &sleeps[before..];
2866 assert_eq!(
2867 paced.len(),
2868 ids.len() + 1,
2869 "one batch call plus four per-id"
2870 );
2871 let min = paced.iter().copied().min().unwrap();
2872 let max = paced.iter().copied().max().unwrap();
2873 let span = max.saturating_sub(min);
2874 assert!(span >= EXPECTED_SPAN.saturating_sub(TOLERANCE));
2879 assert!(span <= EXPECTED_SPAN + TOLERANCE);
2880 }
2881
2882 #[test]
2883 fn get_clip_parent_reads_the_parent_clip() {
2884 let parent = serde_json::json!({
2885 "id": "par", "title": "Ancestor", "status": "complete",
2886 "metadata": {"type": "gen"}
2887 })
2888 .to_string();
2889 let mut rules = auth_rules();
2890 rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
2891 let http = MockHttp::new(rules);
2892 let client = authed_client(&http);
2893
2894 let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
2895 assert_eq!(clip.unwrap().id, "par");
2896 }
2897
2898 #[test]
2899 fn get_clip_parent_is_none_for_a_root() {
2900 let mut rules = auth_rules();
2901 rules.push(Rule::new(
2902 "/api/clips/parent",
2903 404,
2904 r#"{"detail": "no parent"}"#.to_string(),
2905 ));
2906 let http = MockHttp::new(rules);
2907 let client = authed_client(&http);
2908
2909 let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
2910 assert!(clip.is_none());
2911 }
2912
2913 #[test]
2914 fn get_clip_parent_is_none_for_a_200_no_id_root() {
2915 for body in [
2920 r#"{"is_public": false}"#,
2921 r#"{"clip": {"is_public": false}}"#,
2922 ] {
2923 let mut rules = auth_rules();
2924 rules.push(Rule::new("/api/clips/parent", 200, body.to_string()));
2925 let http = MockHttp::new(rules);
2926 let client = authed_client(&http);
2927
2928 let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
2929 assert!(clip.is_none(), "200-no-id body {body:?} must map to None");
2930 }
2931 }
2932
2933 #[test]
2934 fn get_clip_parent_reads_the_reduced_user_prefixed_shape() {
2935 let parent = serde_json::json!({
2939 "id": "00000000-0000-4000-8000-000000000020",
2940 "title": "Track 2",
2941 "is_public": false,
2942 "user_display_name": "Example Artist 4",
2943 "user_handle": "example-artist-1",
2944 "user_avatar_image_url": "https://cdn1.suno.ai/avatar.jpg"
2945 })
2946 .to_string();
2947 let mut rules = auth_rules();
2948 rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
2949 let http = MockHttp::new(rules);
2950 let client = authed_client(&http);
2951
2952 let clip = pollster::block_on(client.get_clip_parent(&http, "child"))
2953 .unwrap()
2954 .expect("a parent clip with an id");
2955 assert_eq!(clip.id, "00000000-0000-4000-8000-000000000020");
2956 assert_eq!(clip.display_name, "Example Artist 4");
2957 assert_eq!(clip.handle, "example-artist-1");
2958 assert_eq!(clip.avatar_image_url, "https://cdn1.suno.ai/avatar.jpg");
2959 }
2960
2961 #[test]
2962 fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
2963 for status in [500u16, 503] {
2967 let mut rules = auth_rules();
2968 rules.push(Rule::new(
2969 "/api/clips/parent",
2970 status,
2971 r#"{"detail": "server error"}"#.to_string(),
2972 ));
2973 let http = MockHttp::new(rules);
2974 let client = authed_client(&http);
2975
2976 let result = pollster::block_on(client.get_clip_parent(&http, "child"));
2977 assert!(
2978 matches!(result, Err(Error::Api(_))),
2979 "status {status} must propagate as an error, not Ok(None)"
2980 );
2981 }
2982 }
2983
2984 #[test]
2985 fn get_playlists_maps_entries_and_skips_missing_ids() {
2986 let page1 = serde_json::json!({
2987 "playlists": [
2988 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
2989 {"id": "", "name": "No Id", "num_total_results": 3},
2990 {"name": "Also No Id"}
2991 ]
2992 })
2993 .to_string();
2994 let mut rules = auth_rules();
2995 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
2997 rules.push(Rule::new(
2998 "/api/playlist/me?page=2",
2999 200,
3000 r#"{"playlists": []}"#.to_string(),
3001 ));
3002 let http = MockHttp::new(rules);
3003 let client = authed_client(&http);
3004
3005 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
3006 assert_eq!(playlists.len(), 1, "entries without an id are dropped");
3007 assert_eq!(
3008 playlists[0],
3009 Playlist {
3010 id: "pl1".to_owned(),
3011 name: "Road Trip".to_owned(),
3012 num_clips: 12,
3013 }
3014 );
3015 }
3016
3017 #[test]
3018 fn get_playlists_defaults_a_missing_name_to_untitled() {
3019 let page1 = serde_json::json!({
3020 "playlists": [{"id": "pl9", "num_total_results": 1}]
3021 })
3022 .to_string();
3023 let mut rules = auth_rules();
3024 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
3025 rules.push(Rule::new(
3026 "/api/playlist/me?page=2",
3027 200,
3028 r#"{"playlists": []}"#.to_string(),
3029 ));
3030 let http = MockHttp::new(rules);
3031 let client = authed_client(&http);
3032
3033 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
3034 assert_eq!(playlists[0].name, "Untitled");
3035 }
3036
3037 #[test]
3038 fn get_playlist_clips_preserves_order_and_unwraps_clip() {
3039 let body = serde_json::json!({
3042 "num_total_results": 2,
3043 "playlist_clips": [
3044 {"clip": {
3045 "id": "second", "title": "Second", "status": "complete",
3046 "metadata": {"duration": 60.0, "type": "gen"}
3047 }},
3048 {"clip": {
3049 "id": "first", "title": "First", "status": "complete",
3050 "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
3051 }}
3052 ]
3053 })
3054 .to_string();
3055 let mut rules = auth_rules();
3056 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3057 let http = MockHttp::new(rules);
3058 let client = authed_client(&http);
3059
3060 let (clips, complete) =
3061 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3062 assert_eq!(clips.len(), 2, "an infill member is not filtered out");
3063 assert_eq!(clips[0].id, "second");
3064 assert_eq!(clips[1].id, "first");
3065 assert!(
3066 complete,
3067 "returned == num_total_results is fully enumerated"
3068 );
3069 }
3070
3071 #[test]
3072 fn get_playlist_clips_short_page_is_not_complete() {
3073 let body = serde_json::json!({
3075 "num_total_results": 5,
3076 "playlist_clips": [
3077 {"clip": {
3078 "id": "only", "title": "Only", "status": "complete",
3079 "metadata": {"duration": 60.0, "type": "gen"}
3080 }}
3081 ]
3082 })
3083 .to_string();
3084 let mut rules = auth_rules();
3085 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3086 let http = MockHttp::new(rules);
3087 let client = authed_client(&http);
3088
3089 let (clips, complete) =
3090 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3091 assert_eq!(clips.len(), 1);
3092 assert!(!complete, "a short page is not fully enumerated");
3093 }
3094
3095 #[test]
3096 fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
3097 let mut rules = auth_rules();
3098 rules.push(Rule::new(
3099 "/api/playlist/empty/",
3100 200,
3101 r#"{"num_total_results": 0, "playlist_clips": []}"#.to_string(),
3102 ));
3103 let http = MockHttp::new(rules);
3104 let client = authed_client(&http);
3105
3106 let (clips, complete) =
3107 pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
3108 assert!(clips.is_empty());
3109 assert!(
3110 complete,
3111 "an empty playlist reporting zero total is complete"
3112 );
3113 }
3114
3115 #[test]
3116 fn get_playlist_clips_missing_total_is_not_complete() {
3117 let mut rules = auth_rules();
3121 rules.push(Rule::new(
3122 "/api/playlist/pl1/",
3123 200,
3124 r#"{"playlist_clips": []}"#.to_string(),
3125 ));
3126 let http = MockHttp::new(rules);
3127 let client = authed_client(&http);
3128
3129 let (clips, complete) =
3130 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3131 assert!(clips.is_empty());
3132 assert!(!complete, "a missing total is never fully enumerated");
3133 }
3134
3135 #[test]
3136 fn get_playlist_clips_dropped_member_disarms_authority() {
3137 let missing_id = serde_json::json!({
3142 "num_total_results": 2,
3143 "playlist_clips": [
3144 {"clip": {
3145 "id": "a", "title": "A", "status": "complete",
3146 "metadata": {"duration": 60.0, "type": "gen"}
3147 }},
3148 {"clip": {
3149 "title": "No Id", "status": "complete",
3150 "metadata": {"duration": 30.0, "type": "gen"}
3151 }}
3152 ]
3153 })
3154 .to_string();
3155 let empty_id = serde_json::json!({
3156 "num_total_results": 2,
3157 "playlist_clips": [
3158 {"clip": {
3159 "id": "a", "title": "A", "status": "complete",
3160 "metadata": {"duration": 60.0, "type": "gen"}
3161 }},
3162 {"clip": {
3163 "id": "", "title": "Empty Id", "status": "complete",
3164 "metadata": {"duration": 30.0, "type": "gen"}
3165 }}
3166 ]
3167 })
3168 .to_string();
3169 for body in [missing_id, empty_id] {
3170 let mut rules = auth_rules();
3171 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3172 let http = MockHttp::new(rules);
3173 let client = authed_client(&http);
3174
3175 let (clips, complete) =
3176 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3177 assert_eq!(clips.len(), 1, "the member with no id is dropped");
3178 assert!(
3179 !complete,
3180 "a dropped member disarms authority even when raw_len == total"
3181 );
3182 }
3183 }
3184
3185 #[test]
3186 fn get_playlist_clips_over_count_is_not_complete() {
3187 let body = serde_json::json!({
3192 "num_total_results": 2,
3193 "playlist_clips": [
3194 {"clip": {
3195 "id": "a", "title": "A", "status": "complete",
3196 "metadata": {"duration": 60.0, "type": "gen"}
3197 }},
3198 {"clip": {
3199 "id": "b", "title": "B", "status": "complete",
3200 "metadata": {"duration": 30.0, "type": "gen"}
3201 }},
3202 {"clip": {
3203 "id": "", "title": "Empty Id", "status": "complete",
3204 "metadata": {"duration": 45.0, "type": "gen"}
3205 }}
3206 ]
3207 })
3208 .to_string();
3209 let mut rules = auth_rules();
3210 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3211 let http = MockHttp::new(rules);
3212 let client = authed_client(&http);
3213
3214 let (clips, complete) =
3215 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3216 assert_eq!(clips.len(), 2, "the empty-id member is dropped");
3217 assert!(
3218 !complete,
3219 "raw_len (3) diverging from the total (2) is not authoritative"
3220 );
3221 }
3222
3223 #[test]
3224 fn get_playlist_clips_ignores_song_count() {
3225 let body = serde_json::json!({
3229 "num_total_results": 1,
3230 "song_count": 0,
3231 "playlist_clips": [
3232 {"clip": {
3233 "id": "only", "title": "Only", "status": "complete",
3234 "metadata": {"duration": 60.0, "type": "gen"}
3235 }}
3236 ]
3237 })
3238 .to_string();
3239 let mut rules = auth_rules();
3240 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3241 let http = MockHttp::new(rules);
3242 let client = authed_client(&http);
3243
3244 let (clips, complete) =
3245 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3246 assert_eq!(clips.len(), 1);
3247 assert!(
3248 complete,
3249 "completeness uses num_total_results, not song_count"
3250 );
3251 }
3252
3253 #[test]
3254 fn get_playlists_num_clips_ignores_song_count() {
3255 let page1 = serde_json::json!({
3258 "playlists": [
3259 {"id": "pl1", "name": "Road Trip", "num_total_results": 15, "song_count": 0}
3260 ]
3261 })
3262 .to_string();
3263 let mut rules = auth_rules();
3264 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
3265 rules.push(Rule::new(
3266 "/api/playlist/me?page=2",
3267 200,
3268 r#"{"playlists": []}"#.to_string(),
3269 ));
3270 let http = MockHttp::new(rules);
3271 let client = authed_client(&http);
3272
3273 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
3274 assert_eq!(
3275 playlists[0].num_clips, 15,
3276 "num_clips reads num_total_results, not song_count"
3277 );
3278 }
3279
3280 #[test]
3281 fn get_playlists_dedupes_a_page_ignoring_server() {
3282 let same_body = serde_json::json!({
3287 "playlists": [
3288 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
3289 {"id": "pl2", "name": "Chill", "num_total_results": 7}
3290 ]
3291 })
3292 .to_string();
3293 let mut rules = auth_rules();
3294 rules.push(Rule::new("/api/playlist/me", 200, same_body));
3295 let http = MockHttp::new(rules);
3296 let client = authed_client(&http);
3297
3298 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
3299 assert_eq!(
3300 playlists.len(),
3301 2,
3302 "duplicates from a page-ignoring server are collapsed"
3303 );
3304 assert_eq!(playlists[0].id, "pl1");
3305 assert_eq!(playlists[1].id, "pl2");
3306 }
3307
3308 #[test]
3309 fn get_playlist_clips_preserves_array_order_over_created_at() {
3310 let body = serde_json::json!({
3314 "num_total_results": 3,
3315 "playlist_clips": [
3316 {"clip": {
3317 "id": "a", "title": "A", "status": "complete",
3318 "metadata": {"duration": 60.0, "type": "gen"}
3319 }, "relative_index": 1.0, "created_at": "2026-06-08T00:00:00.000Z"},
3320 {"clip": {
3321 "id": "b", "title": "B", "status": "complete",
3322 "metadata": {"duration": 30.0, "type": "gen"}
3323 }, "relative_index": 2.0, "created_at": "2026-01-11T00:00:00.000Z"},
3324 {"clip": {
3325 "id": "c", "title": "C", "status": "complete",
3326 "metadata": {"duration": 45.0, "type": "gen"}
3327 }, "relative_index": 3.0, "created_at": "2026-05-15T00:00:00.000Z"}
3328 ]
3329 })
3330 .to_string();
3331 let mut rules = auth_rules();
3332 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3333 let http = MockHttp::new(rules);
3334 let client = authed_client(&http);
3335
3336 let (clips, complete) =
3337 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3338 assert_eq!(
3339 clips.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(),
3340 ["a", "b", "c"],
3341 "array order is preserved despite non-monotonic created_at"
3342 );
3343 assert!(complete, "three intact members equal the declared total");
3344 }
3345
3346 fn stem_page(stems: &[(&str, &str, &str)]) -> String {
3349 let entries: Vec<Value> = stems
3350 .iter()
3351 .map(|(id, label, url)| {
3352 serde_json::json!({
3353 "id": id,
3354 "title": format!("My Song ({label})"),
3355 "status": "complete",
3356 "audio_url": url,
3357 })
3358 })
3359 .collect();
3360 serde_json::json!({ "stems": entries }).to_string()
3361 }
3362
3363 fn stem_pages(pages: u32) -> String {
3365 serde_json::json!({ "pages": pages }).to_string()
3366 }
3367
3368 #[test]
3369 fn list_stems_drains_all_declared_pages_and_is_authoritative() {
3370 let http = ScriptedHttp::new()
3373 .with_auth()
3374 .route("stems/pages", Reply::json(&stem_pages(2)))
3375 .route(
3376 "stems?page=0",
3377 Reply::json(&stem_page(&[
3378 ("s1", "Vocals", "https://cdn1.suno.ai/s1.mp3"),
3379 ("s2", "Drums", "https://cdn1.suno.ai/s2.mp3"),
3380 ])),
3381 )
3382 .route(
3383 "stems?page=1",
3384 Reply::json(&stem_page(&[("s3", "Bass", "https://cdn1.suno.ai/s3.mp3")])),
3385 );
3386 let client = scripted_client(&http, RecordingClock::new());
3387
3388 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3389 assert_eq!(stems.len(), 3);
3390 assert_eq!(stems[0].id, "s1");
3391 assert_eq!(stems[0].label, "Vocals");
3392 assert_eq!(stems[0].url, "https://cdn1.suno.ai/s1.mp3");
3393 assert_eq!(stems[2].label, "Bass");
3394 assert!(
3395 complete,
3396 "a fully drained listing that returned stems is authoritative"
3397 );
3398 }
3399
3400 #[test]
3401 fn list_stems_zero_pages_is_indeterminate_never_empty() {
3402 let http = ScriptedHttp::new()
3405 .with_auth()
3406 .route("stems/pages", Reply::json(&stem_pages(0)));
3407 let client = scripted_client(&http, RecordingClock::new());
3408
3409 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3410 assert!(stems.is_empty());
3411 assert!(
3412 !complete,
3413 "an empty listing is indeterminate, so existing stems are kept"
3414 );
3415 }
3416
3417 #[test]
3418 fn list_stems_missing_page_count_is_indeterminate() {
3419 for status in [400u16, 404] {
3422 let http = ScriptedHttp::new()
3423 .with_auth()
3424 .route("stems/pages", Reply::status(status));
3425 let client = scripted_client(&http, RecordingClock::new());
3426 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3427 assert!(stems.is_empty(), "status {status}");
3428 assert!(!complete, "status {status} is indeterminate, not empty");
3429 }
3430 }
3431
3432 #[test]
3433 fn stem_page_count_5xx_with_invalid_page_body_is_not_no_stems() {
3434 let http = ScriptedHttp::new()
3438 .with_auth()
3439 .route("stems/pages", Reply::with_body(500, "Invalid page"));
3440 let client = scripted_client(&http, RecordingClock::new());
3441
3442 let result = pollster::block_on(client.list_stems(&http, "clip1"));
3443 assert!(
3444 result.is_err(),
3445 "a 5xx is a transient error, never 'no stems'"
3446 );
3447 }
3448
3449 #[test]
3450 fn list_stems_page_error_mid_enumeration_propagates() {
3451 let http = ScriptedHttp::new()
3455 .with_auth()
3456 .route("stems/pages", Reply::json(&stem_pages(2)))
3457 .route(
3458 "stems?page=0",
3459 Reply::json(&stem_page(&[(
3460 "s1",
3461 "Vocals",
3462 "https://cdn1.suno.ai/s1.mp3",
3463 )])),
3464 )
3465 .route("stems?page=1", Reply::status(500));
3466 let client = scripted_client(&http, RecordingClock::new());
3467
3468 let result = pollster::block_on(client.list_stems(&http, "clip1"));
3469 assert!(result.is_err(), "a 5xx page is not a clean drain");
3470 }
3471
3472 #[test]
3473 fn list_stems_over_max_pages_is_truncated_never_authoritative() {
3474 let http = ScriptedHttp::new()
3479 .with_auth()
3480 .route("stems/pages", Reply::json(&stem_pages(MAX_PAGES + 1)))
3481 .route(
3482 "stems?page=",
3483 Reply::json(&stem_page(&[(
3484 "s1",
3485 "Vocals",
3486 "https://cdn1.suno.ai/s1.mp3",
3487 )])),
3488 );
3489 let client = scripted_client(&http, RecordingClock::new());
3490
3491 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3492 assert!(!stems.is_empty(), "the fetched pages still yield stems");
3493 assert!(
3494 !complete,
3495 "a listing declaring more than MAX_PAGES is truncated, never authoritative"
3496 );
3497 }
3498
3499 #[test]
3500 fn parse_stems_page_maps_full_clips_and_skips_idless() {
3501 let page = stem_page(&[("x", "Backing Vocals", "https://cdn1.suno.ai/x.mp3")]);
3504 let stems = parse_stems_page(page.as_bytes());
3505 assert_eq!(stems.len(), 1);
3506 assert_eq!(stems[0].id, "x");
3507 assert_eq!(stems[0].label, "Backing Vocals");
3508 assert_eq!(stems[0].url, "https://cdn1.suno.ai/x.mp3");
3509 let no_id = br#"{"stems": [{"title": "Ghost (Vocals)", "audio_url": "https://cdn1.suno.ai/g.mp3"}]}"#;
3511 assert!(parse_stems_page(no_id).is_empty());
3512 let no_url = br#"{"stems": [{"id": "y", "title": "Song (Bass)"}]}"#;
3515 let recovered = parse_stems_page(no_url);
3516 assert_eq!(recovered.len(), 1);
3517 assert_eq!(recovered[0].url, "https://cdn1.suno.ai/y.mp3");
3518 assert!(parse_stems_page(b"not json").is_empty());
3520 }
3521
3522 #[test]
3523 fn list_stems_labels_the_inferred_populated_page_from_the_stem_group() {
3524 let page = serde_json::json!({
3530 "stems": [{
3531 "id": "stem-bv",
3532 "title": "Track 30",
3533 "status": "complete",
3534 "audio_url": "https://cdn1.suno.ai/stem-bv.mp3",
3535 "metadata": {
3536 "stem_from_id": "source-074",
3537 "stem_task": "twelve",
3538 "stem_type_id": 91.0,
3539 "stem_type_group_name": "Backing_Vocals"
3540 }
3541 }]
3542 })
3543 .to_string();
3544 let http = ScriptedHttp::new()
3545 .with_auth()
3546 .route("stems/pages", Reply::json(&stem_pages(1)))
3547 .route("stems?page=0", Reply::json(&page));
3548 let client = scripted_client(&http, RecordingClock::new());
3549
3550 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3551 assert_eq!(stems.len(), 1);
3552 assert_eq!(stems[0].id, "stem-bv");
3553 assert_eq!(
3554 stems[0].label, "Backing Vocals",
3555 "the underscore group name is normalised, not the empty title parenthetical"
3556 );
3557 assert_eq!(stems[0].url, "https://cdn1.suno.ai/stem-bv.mp3");
3558 assert!(
3559 complete,
3560 "a drained listing that returned a stem is authoritative"
3561 );
3562 }
3563
3564 #[test]
3565 fn stem_label_prefers_the_normalised_group_over_the_title() {
3566 let grouped = Clip {
3568 title: "Track 30".to_owned(),
3569 stem_type_group_name: "Backing_Vocals".to_owned(),
3570 ..Default::default()
3571 };
3572 assert_eq!(stem_label(&grouped), "Backing Vocals");
3573 let both = Clip {
3576 title: "My Song (Guitar)".to_owned(),
3577 stem_type_group_name: "Vocals".to_owned(),
3578 ..Default::default()
3579 };
3580 assert_eq!(stem_label(&both), "Vocals");
3581 let titled = Clip {
3583 title: "My Song (Drums)".to_owned(),
3584 ..Default::default()
3585 };
3586 assert_eq!(stem_label(&titled), "Drums");
3587 let bare = Clip {
3589 title: "Track 31".to_owned(),
3590 ..Default::default()
3591 };
3592 assert_eq!(stem_label(&bare), "");
3593 }
3594
3595 #[test]
3596 fn parse_stem_page_count_reads_pages_field() {
3597 assert_eq!(parse_stem_page_count(br#"{"pages": 12}"#), 12);
3598 assert_eq!(parse_stem_page_count(br#"{"pages": 0}"#), 0);
3599 assert_eq!(parse_stem_page_count(br#"{}"#), 0);
3601 assert_eq!(parse_stem_page_count(br#"{"pages": -1}"#), 0);
3602 assert_eq!(parse_stem_page_count(b"not json"), 0);
3603 }
3604
3605 #[test]
3606 fn stem_label_from_title_extracts_trailing_parenthetical() {
3607 assert_eq!(stem_label_from_title("My Song (Vocals)"), "Vocals");
3608 assert_eq!(
3609 stem_label_from_title("A (b) Song (Backing Vocals)"),
3610 "Backing Vocals"
3611 );
3612 assert_eq!(stem_label_from_title("My Song (Drums) "), "Drums");
3613 assert_eq!(stem_label_from_title("My Song"), "");
3615 assert_eq!(stem_label_from_title(""), "");
3616 }
3617
3618 #[test]
3619 fn post_allow_list_permits_only_feed_and_wav_render() {
3620 assert!(post_path_allowed(FEED_V3_PATH));
3621 assert!(post_path_allowed("/api/gen/abc123/convert_wav/"));
3622 assert!(!post_path_allowed("/api/gen/abc123/stem_task"));
3624 assert!(!post_path_allowed("/api/gen/abc123/separate"));
3625 assert!(!post_path_allowed("/api/gen/a/../evil/convert_wav/"));
3627 assert!(!post_path_allowed("/api/gen/a/b/convert_wav/"));
3628 assert!(!post_path_allowed("/api/clip/x/stems/pages"));
3630 assert!(!post_path_allowed("/api/clip/x/stems?page=0"));
3631 }
3632
3633 #[test]
3634 fn api_request_refuses_a_post_off_the_allow_list() {
3635 let http = MockHttp::new(auth_rules());
3638 let client = authed_client(&http);
3639 let err = pollster::block_on(client.api_request(
3640 &http,
3641 Method::Post,
3642 "/api/gen/x/stem_task",
3643 b"{}".to_vec(),
3644 ))
3645 .unwrap_err();
3646 assert!(matches!(err, Error::Refused(_)));
3647 }
3648}