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(
179 &self,
180 http: &impl Http,
181 liked: bool,
182 limit: Option<usize>,
183 ) -> Result<(Vec<Clip>, bool)> {
184 let mut clips = Vec::new();
185 let mut cursor: Option<String> = None;
186 let mut complete = false;
187 for _ in 0..MAX_PAGES {
188 let body = feed_v3_body(liked, cursor.as_deref());
189 let response = self
190 .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
191 .await?;
192 let (page_clips, has_more, next_cursor) = parse_feed_v3(&response)?;
193 clips.extend(page_clips);
194 match has_more {
195 Some(false) => {
196 complete = true;
197 break;
198 }
199 Some(true) => match next_cursor {
200 Some(next) => cursor = Some(next),
201 None => break,
202 },
203 None => break,
204 }
205 if limit.is_some_and(|n| clips.len() >= n) {
206 break;
207 }
208 }
209 if let Some(n) = limit {
210 clips.truncate(n);
211 }
212 Ok((clips, complete))
213 }
214
215 pub async fn get_clip(&self, http: &impl Http, id: &str) -> Result<Clip> {
220 if let Some(clip) = self.try_get_clip(http, id).await? {
221 return Ok(clip);
222 }
223 self.find_in_feed(http, id).await
224 }
225
226 pub async fn request_wav(&self, http: &impl Http, id: &str) -> Result<()> {
228 let path = format!("/api/gen/{id}/convert_wav/");
229 self.api_request(http, Method::Post, &path, Vec::new())
230 .await?;
231 Ok(())
232 }
233
234 pub async fn wav_url(&self, http: &impl Http, id: &str) -> Result<Option<String>> {
242 let path = format!("/api/gen/{id}/wav_file/");
243 let body = match self.api_get(http, &path).await {
244 Ok(body) => body,
245 Err(Error::NotFound(_)) => return Ok(None),
246 Err(err) => return Err(err),
247 };
248 let data: Value = serde_json::from_slice(&body)
249 .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
250 Ok(data
251 .get("wav_file_url")
252 .and_then(Value::as_str)
253 .filter(|url| !url.is_empty())
254 .map(str::to_string))
255 }
256
257 pub async fn aligned_lyrics(&self, http: &impl Http, id: &str) -> Result<AlignedLyrics> {
272 let path = format!("/api/gen/{id}/aligned_lyrics/v2/");
273 match self.api_get_retrying(http, &path).await {
274 Ok(body) => Ok(AlignedLyrics::from_bytes(&body)),
275 Err(Error::NotFound(_)) => Ok(AlignedLyrics::default()),
276 Err(err) => Err(err),
277 }
278 }
279
280 pub async fn get_clips_by_ids(
302 &self,
303 http: &impl Http,
304 ids: &[&str],
305 concurrency: usize,
306 ) -> Result<Vec<Clip>> {
307 let ordered = dedup_nonempty(ids);
308 let mut found: HashMap<&str, Clip> = self
309 .get_songs_by_ids(http, &ordered)
310 .await?
311 .into_iter()
312 .filter_map(|clip| {
313 ordered
314 .iter()
315 .find(|id| **id == clip.id)
316 .map(|id| (*id, clip))
317 })
318 .collect();
319 let omitted: Vec<&str> = ordered
320 .iter()
321 .copied()
322 .filter(|id| !found.contains_key(id))
323 .collect();
324 if !omitted.is_empty() {
325 for clip in self
326 .fetch_clips_individually(http, &omitted, concurrency)
327 .await?
328 {
329 if let Some(id) = ordered.iter().copied().find(|id| *id == clip.id) {
330 found.insert(id, clip);
331 }
332 }
333 }
334 Ok(ordered.iter().filter_map(|id| found.remove(id)).collect())
335 }
336
337 pub async fn get_songs_by_ids(&self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
358 let ordered = dedup_nonempty(ids);
359 let mut found: HashMap<&str, Clip> = HashMap::new();
360 for chunk in ordered.chunks(GET_SONGS_CHUNK) {
361 let query = chunk
362 .iter()
363 .map(|id| format!("ids={id}"))
364 .collect::<Vec<_>>()
365 .join("&");
366 let path = format!("{GET_SONGS_BY_IDS_PATH}?{query}");
367 let clips = match self.api_get_retrying(http, &path).await {
368 Ok(body) => parse_songs_batch(&body).unwrap_or_default(),
369 Err(err @ (Error::RateLimited { .. } | Error::Auth(_))) => return Err(err),
370 Err(_) => Vec::new(),
371 };
372 for clip in clips {
373 if let Some(id) = chunk.iter().copied().find(|id| *id == clip.id) {
374 found.insert(id, clip);
375 }
376 }
377 }
378 Ok(ordered.iter().filter_map(|id| found.remove(id)).collect())
379 }
380
381 async fn fetch_clips_individually(
390 &self,
391 http: &impl Http,
392 ids: &[&str],
393 concurrency: usize,
394 ) -> Result<Vec<Clip>> {
395 let limit = concurrency.max(1);
396 let fetched = stream::iter(ids.iter().copied())
397 .map(|id| async move {
398 let path = format!("/api/clip/{id}");
399 match self.api_get_retrying(http, &path).await {
400 Ok(body) => Ok(parse_clip(&body)),
401 Err(Error::NotFound(_)) => Ok(None),
402 Err(err) => Err(err),
403 }
404 })
405 .buffered(limit)
406 .collect::<Vec<_>>()
407 .await;
408 let mut clips = Vec::new();
409 for item in fetched {
410 if let Some(clip) = item? {
411 clips.push(clip);
412 }
413 }
414 Ok(clips)
415 }
416
417 pub async fn get_clip_parent(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
427 let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
428 match self.api_get_retrying(http, &path).await {
429 Ok(body) => Ok(parse_clip(&body)),
432 Err(Error::NotFound(_)) => Ok(None),
433 Err(err) => Err(err),
434 }
435 }
436
437 pub async fn get_playlists(&self, http: &impl Http) -> Result<Vec<Playlist>> {
450 let mut playlists = Vec::new();
451 let mut seen = BTreeSet::new();
452 for page in 1..=MAX_PAGES {
453 let path =
454 format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
455 let body = self.api_get_retrying(http, &path).await?;
456 let page_playlists = parse_playlists(&body)?;
457 if page_playlists.is_empty() {
458 break;
459 }
460 for playlist in page_playlists {
461 if seen.insert(playlist.id.clone()) {
462 playlists.push(playlist);
463 }
464 }
465 }
466 Ok(playlists)
467 }
468
469 pub async fn get_playlist_clips(
486 &self,
487 http: &impl Http,
488 id: &str,
489 ) -> Result<(Vec<Clip>, bool)> {
490 let path = format!("{PLAYLIST_PATH}{id}/");
491 let body = self.api_get_retrying(http, &path).await?;
492 parse_playlist_clips(&body)
493 }
494
495 pub async fn get_billing_info(&self, http: &impl Http) -> Result<BillingInfo> {
497 let body = self.api_get_retrying(http, BILLING_INFO_PATH).await?;
498 parse_billing_info(&body)
499 }
500
501 pub async fn list_stems(&self, http: &impl Http, clip_id: &str) -> Result<(Vec<Stem>, bool)> {
525 let declared = self.stem_page_count(http, clip_id).await?;
526 if declared == 0 {
529 return Ok((Vec::new(), false));
530 }
531 let pages = declared.min(MAX_PAGES);
532 let mut stems: Vec<Stem> = Vec::new();
533 for page in 0..pages {
534 let path = format!("/api/clip/{clip_id}/stems?page={page}");
537 let body = self.api_get_retrying(http, &path).await?;
541 stems.extend(parse_stems_page(&body));
542 }
543 dedupe_stems(&mut stems);
544 let complete = !stems.is_empty() && declared <= MAX_PAGES;
550 Ok((stems, complete))
551 }
552
553 async fn stem_page_count(&self, http: &impl Http, clip_id: &str) -> Result<u32> {
561 let path = format!("/api/clip/{clip_id}/stems/pages");
562 match self.api_get_retrying(http, &path).await {
563 Ok(body) => Ok(parse_stem_page_count(&body)),
564 Err(err) if is_invalid_page_error(&err) => Ok(0),
565 Err(Error::NotFound(_)) => Ok(0),
566 Err(err) => Err(err),
567 }
568 }
569
570 async fn try_get_clip(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
573 let path = format!("/api/clip/{id}");
574 match self.api_get_retrying(http, &path).await {
575 Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
576 Err(Error::NotFound(_)) => Ok(None),
577 Err(err) => Err(err),
578 }
579 }
580
581 async fn find_in_feed(&self, http: &impl Http, id: &str) -> Result<Clip> {
583 let (clips, _complete) = self.list_clips(http, false, None).await?;
584 clips
585 .into_iter()
586 .find(|clip| clip.id == id)
587 .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
588 }
589
590 async fn api_get(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
592 self.api_request(http, Method::Get, path, Vec::new()).await
593 }
594
595 async fn api_get_retrying(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
597 self.api_send_retrying(http, Method::Get, path, Vec::new())
598 .await
599 }
600
601 async fn api_send_retrying(
621 &self,
622 http: &impl Http,
623 method: Method,
624 path: &str,
625 body: Vec<u8>,
626 ) -> Result<Vec<u8>> {
627 let pace = self.limiter.lock().unwrap().pace(Instant::now());
628 if !pace.is_zero() {
629 self.clock.sleep(pace).await;
630 }
631 let mut retries = 0;
632 loop {
633 match self.api_request(http, method, path, body.clone()).await {
634 Ok(response) => return Ok(response),
635 Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
636 self.clock.sleep(retry_after_delay(retry_after)).await;
637 retries += 1;
638 }
639 Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
640 self.clock.sleep(backoff_delay(retries, None)).await;
641 retries += 1;
642 }
643 Err(err) => return Err(err),
644 }
645 }
646 }
647
648 async fn api_request(
653 &self,
654 http: &impl Http,
655 method: Method,
656 path: &str,
657 body: Vec<u8>,
658 ) -> Result<Vec<u8>> {
659 if method == Method::Post && !post_path_allowed(path) {
665 return Err(Error::Refused(format!(
666 "POST to {path} is not on the allow-list"
667 )));
668 }
669 let url = format!("{SUNO_API_BASE_URL}{path}");
670 let mut auth_refreshed = false;
671 loop {
672 let jwt = self.auth.ensure_jwt(self.clock.now_unix(), http).await?;
673 let mut request = match method {
674 Method::Get => HttpRequest::get(url.clone()),
675 Method::Post => HttpRequest::post(url.clone(), body.clone()),
676 };
677 request
678 .headers
679 .push(("Authorization".to_string(), format!("Bearer {jwt}")));
680 let response = http
681 .send(request)
682 .await
683 .map_err(|err| Error::Connection(err.to_string()))?;
684 match response.status {
685 200..=299 => {
686 self.limiter.lock().unwrap().on_success();
687 return Ok(response.body);
688 }
689 401 | 403 if !auth_refreshed => {
690 self.auth.invalidate_jwt();
691 auth_refreshed = true;
692 }
693 401 | 403 => {
694 return Err(Error::Auth(format!(
695 "Suno API auth failed with status {}",
696 response.status
697 )));
698 }
699 429 => {
700 self.limiter.lock().unwrap().on_rate_limit();
701 return Err(Error::RateLimited {
702 retry_after: retry_after(&response),
703 });
704 }
705 400 => {
706 let preview: String = String::from_utf8_lossy(&response.body)
707 .chars()
708 .take(200)
709 .collect();
710 return Err(Error::BadRequest(format!(
711 "Suno API returned 400: {preview}"
712 )));
713 }
714 404 => {
715 return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
716 }
717 status => {
718 let preview: String = String::from_utf8_lossy(&response.body)
719 .chars()
720 .take(200)
721 .collect();
722 return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
723 }
724 }
725 }
726 }
727}
728
729fn unwrap_clip(value: &Value) -> &Value {
732 value
733 .get("clip")
734 .filter(|clip| clip.is_object())
735 .unwrap_or(value)
736}
737
738fn post_path_allowed(path: &str) -> bool {
748 if path == FEED_V3_PATH {
749 return true;
750 }
751 if let Some(rest) = path.strip_prefix("/api/gen/")
753 && let Some(id) = rest.strip_suffix("/convert_wav/")
754 {
755 return is_single_id_segment(id);
756 }
757 false
758}
759
760fn is_single_id_segment(segment: &str) -> bool {
764 !segment.is_empty()
765 && !segment.contains('/')
766 && !segment.contains('?')
767 && !segment.contains("..")
768}
769
770fn is_invalid_page_error(err: &Error) -> bool {
775 matches!(err, Error::BadRequest(_))
776}
777
778fn parse_stem_page_count(body: &[u8]) -> u32 {
784 serde_json::from_slice::<Value>(body)
785 .ok()
786 .and_then(|data| data.get("pages").and_then(Value::as_u64))
787 .and_then(|pages| u32::try_from(pages).ok())
788 .unwrap_or(0)
789}
790
791fn parse_stems_page(body: &[u8]) -> Vec<Stem> {
801 let Ok(data) = serde_json::from_slice::<Value>(body) else {
802 return Vec::new();
803 };
804 let items = if let Some(array) = data.as_array() {
805 array.as_slice()
806 } else {
807 data.get("stems")
808 .and_then(Value::as_array)
809 .map(Vec::as_slice)
810 .unwrap_or(&[])
811 };
812 items
813 .iter()
814 .map(parse_stem)
815 .filter(|stem| !stem.id.is_empty() && !stem.url.is_empty())
816 .collect()
817}
818
819fn parse_stem(raw: &Value) -> Stem {
822 let clip = Clip::from_json(raw);
823 Stem {
824 id: clip.id.clone(),
825 label: stem_label(&clip),
826 url: clip.mp3_url(),
827 }
828}
829
830fn stem_label(clip: &Clip) -> String {
835 let group = clip.stem_type_group_name.replace('_', " ");
836 let group = group.trim();
837 if !group.is_empty() {
838 return group.to_string();
839 }
840 stem_label_from_title(&clip.title)
841}
842
843fn stem_label_from_title(title: &str) -> String {
848 let trimmed = title.trim_end();
849 let Some(before_close) = trimmed.strip_suffix(')') else {
850 return String::new();
851 };
852 match before_close.rfind('(') {
853 Some(open) => before_close[open + 1..].trim().to_string(),
854 None => String::new(),
855 }
856}
857
858fn dedupe_stems(stems: &mut Vec<Stem>) {
861 let mut seen = BTreeSet::new();
862 stems.retain(|stem| seen.insert(stem.url.clone()));
863}
864
865fn parse_clip(body: &[u8]) -> Option<Clip> {
868 let data: Value = serde_json::from_slice(body).ok()?;
869 let raw = unwrap_clip(&data);
870 let has_id = raw
871 .get("id")
872 .and_then(Value::as_str)
873 .is_some_and(|id| !id.is_empty());
874 has_id.then(|| Clip::from_json(raw))
875}
876
877fn dedup_nonempty<'a>(ids: &[&'a str]) -> Vec<&'a str> {
880 let mut seen: BTreeSet<&str> = BTreeSet::new();
881 ids.iter()
882 .copied()
883 .filter(|id| !id.is_empty() && seen.insert(id))
884 .collect()
885}
886
887fn parse_songs_batch(body: &[u8]) -> Option<Vec<Clip>> {
892 let data: Value = serde_json::from_slice(body).ok()?;
893 let clips = data.get("clips")?.as_array()?;
894 Some(
895 clips
896 .iter()
897 .map(Clip::from_json)
898 .filter(|clip| !clip.id.is_empty())
899 .collect(),
900 )
901}
902
903fn parse_billing_info(body: &[u8]) -> Result<BillingInfo> {
908 let data: Value = serde_json::from_slice(body)
909 .map_err(|err| Error::Api(format!("invalid billing JSON: {err}")))?;
910 Ok(from_billing_json(&data))
911}
912
913fn from_billing_json(data: &Value) -> BillingInfo {
920 let plan = data.get("plan");
921 let mut features = BTreeSet::new();
922 collect_feature_names(data.get("accessible_features"), &mut features);
923 collect_feature_names(
924 plan.and_then(|plan| plan.get("usage_plan_features")),
925 &mut features,
926 );
927 BillingInfo {
928 total_credits_left: data.get("total_credits_left").and_then(json_i64),
929 monthly_limit: data.get("monthly_limit").and_then(json_i64),
930 monthly_usage: data.get("monthly_usage").and_then(json_i64),
931 credits: data.get("credits").and_then(json_i64),
932 period: json_string(data.get("period")),
933 period_end: json_string(data.get("period_end")),
934 renews_on: json_string(data.get("renews_on")),
935 is_active: data.get("is_active").and_then(Value::as_bool),
936 is_paused: data.get("is_paused").and_then(Value::as_bool),
937 is_past_due: data.get("is_past_due").and_then(Value::as_bool),
938 is_gifted: data.get("is_gifted").and_then(Value::as_bool),
939 subscription_platform: json_string(data.get("subscription_platform")),
940 plan_key: json_string(plan.and_then(|plan| plan.get("plan_key"))),
941 plan_name: json_string(plan.and_then(|plan| plan.get("name"))),
942 plan_level: plan.and_then(|plan| plan.get("level")).and_then(json_i64),
943 features,
944 }
945}
946
947fn collect_feature_names(array: Option<&Value>, out: &mut BTreeSet<String>) {
950 let Some(items) = array.and_then(Value::as_array) else {
951 return;
952 };
953 for name in items
954 .iter()
955 .filter_map(|item| item.get("name").and_then(Value::as_str))
956 {
957 if !name.is_empty() {
958 out.insert(name.to_owned());
959 }
960 }
961}
962
963fn json_string(value: Option<&Value>) -> Option<String> {
965 value.and_then(Value::as_str).map(str::to_owned)
966}
967
968fn json_i64(value: &Value) -> Option<i64> {
974 match value {
975 Value::Number(number) => number
976 .as_i64()
977 .or_else(|| number.as_f64().and_then(f64_to_i64)),
978 Value::String(text) => str_to_i64(text),
979 _ => None,
980 }
981}
982
983fn f64_to_i64(value: f64) -> Option<i64> {
986 if value.is_finite() && value.fract() == 0.0 && value.abs() < 9_007_199_254_740_992.0 {
990 Some(value as i64)
991 } else {
992 None
993 }
994}
995
996fn str_to_i64(text: &str) -> Option<i64> {
999 match text.split_once('.') {
1000 Some((integer, fraction)) => {
1001 let integral = fraction.is_empty() || fraction.bytes().all(|byte| byte == b'0');
1002 integral.then(|| integer.parse().ok()).flatten()
1003 }
1004 None => text.parse().ok(),
1005 }
1006}
1007
1008fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
1015 let mut filters = serde_json::Map::new();
1016 filters.insert("trashed".to_string(), Value::String("False".to_string()));
1017 if liked {
1018 filters.insert("liked".to_string(), Value::String("True".to_string()));
1019 }
1020 let mut body = serde_json::Map::new();
1021 body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
1022 body.insert("filters".to_string(), Value::Object(filters));
1023 if let Some(cursor) = cursor {
1024 body.insert("cursor".to_string(), Value::String(cursor.to_string()));
1025 }
1026 serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
1027}
1028
1029fn parse_feed_v3(body: &[u8]) -> Result<(Vec<Clip>, Option<bool>, Option<String>)> {
1036 let data: Value = serde_json::from_slice(body)
1037 .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
1038 let Some(object) = data.as_object() else {
1039 return Ok((Vec::new(), None, None));
1040 };
1041 let clips = object
1042 .get("clips")
1043 .and_then(Value::as_array)
1044 .map(|raw| {
1045 raw.iter()
1046 .map(Clip::from_json)
1047 .filter(is_downloadable)
1048 .collect()
1049 })
1050 .unwrap_or_default();
1051 let has_more = object.get("has_more").and_then(Value::as_bool);
1052 let next_cursor = object
1053 .get("next_cursor")
1054 .and_then(Value::as_str)
1055 .filter(|cursor| !cursor.is_empty())
1056 .map(str::to_string);
1057 Ok((clips, has_more, next_cursor))
1058}
1059
1060fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
1062 let data: Value = serde_json::from_slice(body)
1063 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
1064 Ok(data
1065 .get("playlists")
1066 .and_then(Value::as_array)
1067 .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
1068 .unwrap_or_default())
1069}
1070
1071fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
1076 let id = raw
1077 .get("id")
1078 .and_then(Value::as_str)
1079 .filter(|id| !id.is_empty())?
1080 .to_string();
1081 let name = match raw.get("name") {
1082 Some(Value::String(name)) if !name.is_empty() => name.clone(),
1083 _ => "Untitled".to_string(),
1084 };
1085 let num_clips = raw
1086 .get("num_total_results")
1087 .and_then(Value::as_u64)
1088 .unwrap_or(0);
1089 Some(Playlist {
1090 id,
1091 name,
1092 num_clips,
1093 })
1094}
1095
1096fn parse_playlist_clips(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
1114 let data: Value = serde_json::from_slice(body)
1115 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
1116 let raw = data.get("playlist_clips").and_then(Value::as_array);
1117 let raw_len = raw.map(|a| a.len()).unwrap_or(0);
1118 let clips: Vec<Clip> = raw
1119 .map(|raw| {
1120 raw.iter()
1121 .map(|entry| Clip::from_json(unwrap_clip(entry)))
1122 .filter(|clip| !clip.id.is_empty())
1123 .collect()
1124 })
1125 .unwrap_or_default();
1126 let complete = data
1133 .get("num_total_results")
1134 .and_then(Value::as_u64)
1135 .is_some_and(|total| raw_len as u64 == total && clips.len() == raw_len);
1136 Ok((clips, complete))
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141 use super::*;
1142 use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
1143 use std::time::Duration;
1144
1145 fn feed_body() -> String {
1146 serde_json::json!({
1147 "has_more": false,
1148 "clips": [
1149 {
1150 "id": "a", "title": "Song A", "status": "complete",
1151 "audio_url": "https://cdn1.suno.ai/a.mp3",
1152 "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
1153 },
1154 {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
1155 {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
1156 {
1157 "id": "d", "title": "Context", "status": "complete",
1158 "metadata": {"type": "rendered_context_window"}
1159 }
1160 ]
1161 })
1162 .to_string()
1163 }
1164
1165 #[test]
1166 fn parse_feed_v3_filters_and_reads_pagination() {
1167 let (clips, has_more, next_cursor) = parse_feed_v3(feed_body().as_bytes()).unwrap();
1168 assert_eq!(has_more, Some(false));
1169 assert_eq!(next_cursor, None);
1170 assert_eq!(clips.len(), 1);
1171 assert_eq!(clips[0].id, "a");
1172 assert_eq!(clips[0].tags, "rock");
1173 assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
1174 }
1175
1176 const FEED_V3_PAGE: &str = r#"{
1180 "clips": [
1181 {
1182 "status": "complete",
1183 "title": "Track 31",
1184 "id": "00000000-0000-4000-8000-000000000076",
1185 "entity_type": "song_schema",
1186 "video_url": "",
1187 "audio_url": "https://cdn1.suno.ai/00000000-0000-4000-8000-000000000076.mp3",
1188 "media_urls": [
1189 {
1190 "url": "https://media.cloudfront.net/1/clip/00000000-0000-4000-8000-000000000076.m4a",
1191 "content_type": "m4a-opus",
1192 "delivery": "progressive",
1193 "encoding": "1.0.0"
1194 },
1195 {
1196 "url": "https://cdn1.suno.ai/00000000-0000-4000-8000-000000000076.mp3",
1197 "content_type": "mp3",
1198 "delivery": "progressive"
1199 }
1200 ],
1201 "image_url": "https://cdn2.suno.ai/image_00000000-0000-4000-8000-000000000076.jpeg",
1202 "image_large_url": "https://cdn2.suno.ai/image_large_00000000-0000-4000-8000-000000000076.jpeg",
1203 "major_model_version": "v4.5",
1204 "model_name": "chirp-ahi",
1205 "metadata": {
1206 "tags": "",
1207 "type": "gen",
1208 "duration": 272.0,
1209 "task": "gen_stem",
1210 "has_stem": false
1211 },
1212 "is_liked": false,
1213 "user_id": "00000000-0000-4000-8000-000000000019",
1214 "display_name": "Example Artist 4",
1215 "handle": "example-artist-1",
1216 "is_trashed": false,
1217 "is_hidden": false,
1218 "created_at": "2026-07-03T13:15:10.635Z",
1219 "is_public": false,
1220 "explicit": false,
1221 "batch_index": 23,
1222 "clip_roots": {
1223 "clips": [
1224 {
1225 "id": "00000000-0000-4000-8000-000000000028",
1226 "title": "Track 7",
1227 "image_url": "https://cdn2.suno.ai/image_00000000-0000-4000-8000-000000000028.jpeg",
1228 "is_public": false,
1229 "user_display_name": "Example Artist 4",
1230 "user_handle": "example-artist-1",
1231 "user_avatar_image_url": "https://cdn1.suno.ai/avatar.jpg"
1232 }
1233 ],
1234 "clip_attribution_type": "remix"
1235 }
1236 }
1237 ],
1238 "has_more": true,
1239 "next_cursor": "cursor-token"
1240 }"#;
1241
1242 #[test]
1243 fn parse_feed_v3_page_maps_real_body_and_pagination() {
1244 let (clips, has_more, next_cursor) = parse_feed_v3(FEED_V3_PAGE.as_bytes()).unwrap();
1245 assert_eq!(has_more, Some(true));
1246 assert_eq!(next_cursor.as_deref(), Some("cursor-token"));
1247 assert_eq!(clips.len(), 1);
1249 let clip = &clips[0];
1250 assert_eq!(clip.id, "00000000-0000-4000-8000-000000000076");
1251 assert_eq!(clip.title, "Track 31");
1252 assert_eq!(clip.model_name, "chirp-ahi");
1253 assert_eq!(clip.major_model_version, "v4.5");
1254 assert_eq!(clip.user_id, "00000000-0000-4000-8000-000000000019");
1255 assert_eq!(clip.batch_index, Some(23));
1256 assert_eq!(
1258 clip.image_url,
1259 "https://cdn1.suno.ai/image_00000000-0000-4000-8000-000000000076.jpeg"
1260 );
1261 assert!(clip.image_large_url.starts_with("https://cdn1.suno.ai/"));
1262 assert_eq!(clip.media_urls.len(), 2);
1264 assert_eq!(clip.media_urls[0].content_type, "m4a-opus");
1265 assert_eq!(
1266 clip.mp3_url(),
1267 "https://cdn1.suno.ai/00000000-0000-4000-8000-000000000076.mp3"
1268 );
1269 assert_eq!(clip.clip_attribution_type, "remix");
1271 assert_eq!(clip.clip_roots.len(), 1);
1272 assert_eq!(
1273 clip.clip_roots[0].id,
1274 "00000000-0000-4000-8000-000000000028"
1275 );
1276 assert_eq!(clip.clip_roots[0].handle, "example-artist-1");
1277 }
1278
1279 #[test]
1280 fn parse_feed_v3_page_survives_stripped_optional_fields() {
1281 let stripped = serde_json::json!({
1284 "clips": [{
1285 "id": "bare", "title": "Bare", "status": "complete",
1286 "metadata": {"type": "gen"}
1287 }],
1288 "has_more": false
1289 })
1290 .to_string();
1291 let (clips, has_more, next_cursor) = parse_feed_v3(stripped.as_bytes()).unwrap();
1292 assert_eq!(has_more, Some(false));
1293 assert_eq!(next_cursor, None);
1294 assert_eq!(clips.len(), 1);
1295 assert!(clips[0].media_urls.is_empty());
1296 assert_eq!(clips[0].user_id, "");
1297 assert_eq!(clips[0].batch_index, None);
1298 }
1299
1300 #[test]
1301 fn feed_v3_body_carries_filters_and_optional_cursor() {
1302 let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
1303 assert_eq!(first["filters"]["trashed"], "False");
1304 assert!(first.get("cursor").is_none());
1305 assert!(first["filters"].get("liked").is_none());
1306
1307 let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
1308 assert_eq!(liked["filters"]["liked"], "True");
1309 assert_eq!(liked["cursor"], "cur42");
1310 }
1311
1312 #[test]
1313 fn audiopipe_url_is_rewritten_to_cdn() {
1314 let raw =
1315 serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
1316 assert_eq!(
1317 Clip::from_json(&raw).audio_url,
1318 "https://cdn1.suno.ai/x.mp3"
1319 );
1320 }
1321
1322 #[test]
1323 fn list_clips_authenticates_then_reads_the_feed() {
1324 let client_body = serde_json::json!({
1325 "response": {
1326 "last_active_session_id": "s",
1327 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
1328 }
1329 })
1330 .to_string();
1331 let http = MockHttp::new(vec![
1332 Rule::new(
1333 "/v1/client/sessions/",
1334 200,
1335 r#"{"jwt": "a.b.c"}"#.to_string(),
1336 ),
1337 Rule::new("/v1/client", 200, client_body),
1338 Rule::new("/api/feed/v3", 200, feed_body()),
1339 ]);
1340
1341 let auth = ClerkAuth::new("eyJtoken");
1342 pollster::block_on(auth.authenticate(&http)).unwrap();
1343 let client = SunoClient::new(auth, RecordingClock::new());
1344 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1345 assert_eq!(clips.len(), 1);
1346 assert_eq!(clips[0].id, "a");
1347 assert!(complete);
1348 }
1349
1350 #[test]
1351 fn api_request_uses_clock_now_unix_for_jwt_expiry() {
1352 use crate::consts::JWT_REFRESH_BUFFER;
1353 use base64::Engine;
1354 let exp = 1_000_000i64;
1355 let payload =
1356 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#));
1357 let jwt_str = format!("hdr.{}.sig", payload);
1358 let token_body = format!(r#"{{"jwt": "{jwt_str}"}}"#);
1359 let client_body = serde_json::json!({
1360 "response": {
1361 "last_active_session_id": "s",
1362 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
1363 }
1364 })
1365 .to_string();
1366
1367 let make_http = || {
1368 ScriptedHttp::new()
1369 .route("/v1/client/sessions/", Reply::json(&token_body))
1370 .route("/v1/client", Reply::json(&client_body))
1371 .route("/api/feed/v3", Reply::json(&feed_body()))
1372 };
1373
1374 let http = make_http();
1376 let auth = ClerkAuth::new("eyJtoken");
1377 pollster::block_on(auth.authenticate(&http)).unwrap();
1378 let client = SunoClient::new(auth, RecordingClock::at(exp - JWT_REFRESH_BUFFER));
1379 let (clips, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1380 assert_eq!(clips.len(), 1);
1381 assert_eq!(http.count("/v1/client/sessions/"), 2);
1383
1384 let http2 = make_http();
1386 let auth2 = ClerkAuth::new("eyJtoken");
1387 pollster::block_on(auth2.authenticate(&http2)).unwrap();
1388 let client2 = SunoClient::new(auth2, RecordingClock::at(exp - JWT_REFRESH_BUFFER - 1));
1389 let (clips2, _) = pollster::block_on(client2.list_clips(&http2, false, None)).unwrap();
1390 assert_eq!(clips2.len(), 1);
1391 assert_eq!(http2.count("/v1/client/sessions/"), 1);
1393 }
1394
1395 #[test]
1396 fn list_clips_reports_incomplete_when_paging_is_capped() {
1397 let mut rules = auth_rules();
1398 rules.push(Rule::new(
1399 "/api/feed/v3",
1400 200,
1401 serde_json::json!({
1402 "has_more": true,
1403 "next_cursor": "cur1",
1404 "clips": [{
1405 "id": "a", "title": "Song A", "status": "complete",
1406 "audio_url": "https://cdn1.suno.ai/a.mp3",
1407 "metadata": {"type": "gen"}
1408 }]
1409 })
1410 .to_string(),
1411 ));
1412 let http = MockHttp::new(rules);
1413 let client = authed_client(&http);
1414
1415 let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1416 assert!(!complete);
1417 }
1418
1419 fn auth_rules() -> Vec<Rule> {
1420 let client_body = serde_json::json!({
1421 "response": {
1422 "last_active_session_id": "s",
1423 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
1424 }
1425 })
1426 .to_string();
1427 vec![
1428 Rule::new(
1429 "/v1/client/sessions/",
1430 200,
1431 r#"{"jwt": "a.b.c"}"#.to_string(),
1432 ),
1433 Rule::new("/v1/client", 200, client_body),
1434 ]
1435 }
1436
1437 fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
1438 let auth = ClerkAuth::new("eyJtoken");
1439 pollster::block_on(auth.authenticate(http)).unwrap();
1440 SunoClient::new(auth, RecordingClock::new())
1441 }
1442
1443 #[test]
1444 fn get_billing_info_reads_remaining_credits() {
1445 let mut rules = auth_rules();
1446 rules.push(Rule::new(
1447 BILLING_INFO_PATH,
1448 200,
1449 r#"{"total_credits_left":500,"monthly_limit":1000,"monthly_usage":500}"#.to_string(),
1450 ));
1451 let http = MockHttp::new(rules);
1452 let client = authed_client(&http);
1453
1454 let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
1455 assert_eq!(billing.total_credits_left, Some(500));
1456 assert_eq!(billing.monthly_limit, Some(1000));
1457 assert_eq!(billing.monthly_usage, Some(500));
1458 }
1459
1460 #[test]
1461 fn get_billing_info_tolerates_missing_balance() {
1462 let mut rules = auth_rules();
1463 rules.push(Rule::new(
1464 BILLING_INFO_PATH,
1465 200,
1466 r#"{"monthly_usage":12}"#.to_string(),
1467 ));
1468 let http = MockHttp::new(rules);
1469 let client = authed_client(&http);
1470
1471 let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
1472 assert_eq!(billing.total_credits_left, None);
1473 assert_eq!(billing.monthly_usage, Some(12));
1474 }
1475
1476 const BILLING_FULL: &str = r#"{
1479 "subscription_platform": "stripe",
1480 "is_active": true,
1481 "is_past_due": false,
1482 "credits": 0,
1483 "subscription_type": true,
1484 "subscription_anchor": "REDACTED",
1485 "subscription_id": "REDACTED",
1486 "renews_on": "REDACTED",
1487 "period": "month",
1488 "monthly_usage": 50,
1489 "monthly_limit": 2500,
1490 "credit_packs": [
1491 {
1492 "id": "00000000-0000-4000-8000-000000000001",
1493 "amount": 500,
1494 "price_usd": 4
1495 },
1496 {
1497 "id": "00000000-0000-4000-8000-000000000002",
1498 "amount": 1000,
1499 "price_usd": 8
1500 }
1501 ],
1502 "plan": {
1503 "id": "00000000-0000-4000-8000-000000000005",
1504 "level": 10,
1505 "plan_key": "pro",
1506 "name": "Pro Plan",
1507 "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",
1508 "monthly_price_usd": 10.0,
1509 "annual_price_usd": 96.0,
1510 "usage_plan_features": [
1511 {
1512 "name": "v4"
1513 },
1514 {
1515 "name": "cover"
1516 },
1517 {
1518 "name": "edit_mode"
1519 },
1520 {
1521 "name": "persona"
1522 },
1523 {
1524 "name": "can_buy_credit_top_ups"
1525 },
1526 {
1527 "name": "commercial_rights"
1528 },
1529 {
1530 "name": "get_stems"
1531 },
1532 {
1533 "name": "generate_song_image"
1534 },
1535 {
1536 "name": "auk"
1537 },
1538 {
1539 "name": "negative_tags"
1540 },
1541 {
1542 "name": "remaster"
1543 },
1544 {
1545 "name": "generate_song_video"
1546 },
1547 {
1548 "name": "long_uploads"
1549 },
1550 {
1551 "name": "convert_audio"
1552 },
1553 {
1554 "name": "create_control_sliders"
1555 },
1556 {
1557 "name": "playlist_condition"
1558 },
1559 {
1560 "name": "tag_upsample"
1561 },
1562 {
1563 "name": "custom_models"
1564 }
1565 ]
1566 },
1567 "models": [
1568 {
1569 "can_use": true,
1570 "max_lengths": {
1571 "title": 100,
1572 "prompt": 5000,
1573 "tags": 1000,
1574 "negative_tags": 1000,
1575 "gpt_description_prompt": 3000
1576 },
1577 "name": "Example Artist 5",
1578 "external_key": "chirp-fenix",
1579 "major_version": 5,
1580 "description": "[description redacted]",
1581 "is_default_free_model": false,
1582 "is_default_model": true,
1583 "badges": [
1584 "pro"
1585 ],
1586 "model_badges": [
1587 {
1588 "display_name": "Example Artist 1",
1589 "light": {
1590 "text_color": "000000",
1591 "background_color": "00000000",
1592 "border_color": "000000"
1593 },
1594 "dark": {
1595 "text_color": "FFFFFF",
1596 "background_color": "00000000",
1597 "border_color": "FFFFFF"
1598 }
1599 }
1600 ],
1601 "style": {
1602 "light": {
1603 "text_color": "FD429C"
1604 },
1605 "dark": {
1606 "text_color": "FD429C"
1607 }
1608 },
1609 "capabilities": [
1610 "all"
1611 ],
1612 "features": [
1613 "create_control_sliders",
1614 "tag_upsample",
1615 "mumble_mode",
1616 "vox_and_voices",
1617 "reuse_styles_lyrics"
1618 ],
1619 "allowed_condition_combinations": [
1620 [
1621 "extend"
1622 ],
1623 [
1624 "cover"
1625 ],
1626 [
1627 "infill"
1628 ],
1629 [
1630 "persona"
1631 ],
1632 [
1633 "persona",
1634 "extend"
1635 ],
1636 [
1637 "persona",
1638 "cover"
1639 ],
1640 [
1641 "playlist"
1642 ],
1643 [
1644 "underpaint"
1645 ],
1646 [
1647 "overpaint"
1648 ],
1649 [
1650 "vox"
1651 ],
1652 [
1653 "vox",
1654 "extend"
1655 ],
1656 [
1657 "vox",
1658 "cover"
1659 ],
1660 [
1661 "vox",
1662 "playlist"
1663 ],
1664 [
1665 "persona",
1666 "infill"
1667 ],
1668 [
1669 "cover",
1670 "infill"
1671 ]
1672 ],
1673 "id": "00000000-0000-4000-8000-000000000006"
1674 }
1675 ],
1676 "plan_price": 10.0,
1677 "plan_currency": "AUD",
1678 "plan_currency_price": 15.0,
1679 "payment_method_type": "card",
1680 "can_upgrade_immediately": true,
1681 "plans": [
1682 {
1683 "id": "00000000-0000-4000-8000-000000000015",
1684 "level": 0,
1685 "plan_key": "free",
1686 "name": "Free Plan",
1687 "features": "50 credits renew daily (10 songs)\nCreate up to 4 songs at once\nNo commercial use\nNo credit top ups\nShared generation queue",
1688 "monthly_price_usd": 0.0,
1689 "annual_price_usd": 0.0,
1690 "usage_plan_features": [
1691 {
1692 "name": "tag_upsample"
1693 }
1694 ],
1695 "prices": []
1696 }
1697 ],
1698 "accessible_features": [
1699 {
1700 "name": "v4"
1701 },
1702 {
1703 "name": "cover"
1704 },
1705 {
1706 "name": "edit_mode"
1707 },
1708 {
1709 "name": "persona"
1710 },
1711 {
1712 "name": "can_buy_credit_top_ups"
1713 },
1714 {
1715 "name": "commercial_rights"
1716 },
1717 {
1718 "name": "get_stems"
1719 },
1720 {
1721 "name": "generate_song_image"
1722 },
1723 {
1724 "name": "auk"
1725 },
1726 {
1727 "name": "negative_tags"
1728 },
1729 {
1730 "name": "remaster"
1731 },
1732 {
1733 "name": "generate_song_video"
1734 },
1735 {
1736 "name": "long_uploads"
1737 },
1738 {
1739 "name": "convert_audio"
1740 },
1741 {
1742 "name": "create_control_sliders"
1743 },
1744 {
1745 "name": "playlist_condition"
1746 },
1747 {
1748 "name": "tag_upsample"
1749 },
1750 {
1751 "name": "custom_models"
1752 }
1753 ],
1754 "revcat_subscriptions_offering_id": "REDACTED",
1755 "total_credits_left": 2450,
1756 "free_persona_clips_remaining": 0,
1757 "free_cover_clips_remaining": 0,
1758 "free_remasters_remaining": 0,
1759 "free_mobile_remasters_remaining": 0,
1760 "free_mobile_v4_gens_remaining": 0,
1761 "free_web_v4_gens_remaining": 0,
1762 "free_vox_gens_remaining": 0,
1763 "has_been_subscriber_before": true,
1764 "has_valid_school_email": false,
1765 "has_been_student_subscriber_before": false,
1766 "day0_boost": -1,
1767 "promotions": [],
1768 "audio_upload_limits": {
1769 "min": 6,
1770 "max": 1800
1771 },
1772 "voice_upload_limits": {
1773 "min": 10,
1774 "max": 900
1775 },
1776 "voice_record_limits": {
1777 "min": 10,
1778 "max": 240
1779 },
1780 "period_end": "REDACTED",
1781 "remaster_model_types": [
1782 {
1783 "name": "Example Artist 5",
1784 "external_key": "chirp-flounder",
1785 "is_default_model": true,
1786 "can_use": false
1787 },
1788 {
1789 "name": "Example Artist 2",
1790 "external_key": "chirp-carp",
1791 "is_default_model": false,
1792 "can_use": false
1793 },
1794 {
1795 "name": "v4.5+",
1796 "external_key": "chirp-bass",
1797 "is_default_model": false,
1798 "can_use": false
1799 }
1800 ],
1801 "is_pause_scheduled": false,
1802 "is_paused": false,
1803 "is_gifted": false
1804}"#;
1805
1806 #[test]
1807 fn parse_billing_info_reads_full_real_body() {
1808 let billing = parse_billing_info(BILLING_FULL.as_bytes()).unwrap();
1809 assert_eq!(billing.total_credits_left, Some(2450));
1810 assert_eq!(billing.monthly_limit, Some(2500));
1811 assert_eq!(billing.monthly_usage, Some(50));
1812 assert_eq!(billing.credits, Some(0));
1813 assert_eq!(billing.period.as_deref(), Some("month"));
1814 assert_eq!(billing.is_active, Some(true));
1815 assert_eq!(billing.is_paused, Some(false));
1816 assert_eq!(billing.is_past_due, Some(false));
1817 assert_eq!(billing.is_gifted, Some(false));
1818 assert_eq!(billing.subscription_platform.as_deref(), Some("stripe"));
1819 assert_eq!(billing.plan_key.as_deref(), Some("pro"));
1820 assert_eq!(billing.plan_name.as_deref(), Some("Pro Plan"));
1821 assert_eq!(billing.plan_level, Some(10));
1822 assert!(billing.can_get_stems());
1823 assert!(billing.can_convert_audio());
1824 assert!(billing.has_feature("custom_models"));
1825 }
1826
1827 #[test]
1828 fn json_i64_reads_string_encoded_integer() {
1829 let billing = parse_billing_info(br#"{"total_credits_left":"2450"}"#).unwrap();
1830 assert_eq!(billing.total_credits_left, Some(2450));
1831 }
1832
1833 #[test]
1834 fn json_i64_reads_integral_float() {
1835 let billing = parse_billing_info(br#"{"total_credits_left":2450.0}"#).unwrap();
1836 assert_eq!(billing.total_credits_left, Some(2450));
1837 }
1838
1839 #[test]
1840 fn json_i64_reads_negative_sentinel() {
1841 let billing = parse_billing_info(br#"{"total_credits_left":-1}"#).unwrap();
1842 assert_eq!(billing.total_credits_left, Some(-1));
1843 }
1844
1845 #[test]
1846 fn json_i64_rejects_non_integral_float_but_object_still_parses() {
1847 let billing =
1848 parse_billing_info(br#"{"total_credits_left":2450.5,"period":"month"}"#).unwrap();
1849 assert_eq!(billing.total_credits_left, None);
1850 assert_eq!(billing.period.as_deref(), Some("month"));
1851 }
1852
1853 #[test]
1854 fn str_to_i64_handles_encodings_and_junk() {
1855 assert_eq!(str_to_i64("2450"), Some(2450));
1856 assert_eq!(str_to_i64("2450.0"), Some(2450));
1857 assert_eq!(str_to_i64("-1"), Some(-1));
1858 assert_eq!(str_to_i64("2450.5"), None);
1859 assert_eq!(str_to_i64(".5"), None);
1860 assert_eq!(str_to_i64("nope"), None);
1861 assert_eq!(str_to_i64("99999999999999999999999"), None);
1862 }
1863
1864 #[test]
1865 fn json_i64_rejects_overflow() {
1866 let billing =
1867 parse_billing_info(br#"{"total_credits_left":99999999999999999999999}"#).unwrap();
1868 assert_eq!(billing.total_credits_left, None);
1869 }
1870
1871 #[test]
1872 fn json_i64_covers_i64_and_float_boundaries() {
1873 assert_eq!(json_i64(&serde_json::json!(i64::MAX)), Some(i64::MAX));
1875 assert_eq!(json_i64(&serde_json::json!(i64::MIN)), Some(i64::MIN));
1876 assert_eq!(
1878 json_i64(&serde_json::json!(9_223_372_036_854_775_808_u64)),
1879 None
1880 );
1881 assert_eq!(f64_to_i64(i64::MAX as f64), None);
1883 assert_eq!(f64_to_i64(i64::MIN as f64), None);
1884 assert_eq!(f64_to_i64(2450.5), None);
1885 assert_eq!(f64_to_i64(f64::NAN), None);
1886 assert_eq!(f64_to_i64(f64::INFINITY), None);
1887 }
1888
1889 #[test]
1890 fn f64_to_i64_rejects_values_below_i64_min() {
1891 let below_min: f64 = "-9223372036854775809".parse().unwrap();
1893 assert_eq!(f64_to_i64(below_min), None);
1894 assert_eq!(str_to_i64("-9223372036854775809"), None);
1896 assert_eq!(json_i64(&serde_json::json!("-9223372036854775809")), None);
1897 }
1898
1899 #[test]
1900 fn f64_to_i64_trusts_only_the_safe_integer_range() {
1901 assert_eq!(
1903 f64_to_i64(9_007_199_254_740_991.0),
1904 Some(9_007_199_254_740_991)
1905 );
1906 let rounded: f64 = "9007199254740993".parse().unwrap();
1909 assert_eq!(rounded, 9_007_199_254_740_992.0);
1910 assert_eq!(f64_to_i64(rounded), None);
1911 }
1912
1913 #[test]
1914 fn parse_billing_info_defaults_missing_fields() {
1915 let billing = parse_billing_info(br#"{"monthly_usage":12}"#).unwrap();
1916 assert_eq!(billing.total_credits_left, None);
1917 assert_eq!(billing.monthly_usage, Some(12));
1918 assert_eq!(billing.plan_key, None);
1919 assert!(billing.features.is_empty());
1920 assert!(!billing.can_get_stems());
1921 }
1922
1923 #[test]
1924 fn from_billing_json_ignores_surprising_types() {
1925 let value = serde_json::json!({
1928 "subscription_type": true,
1929 "total_credits_left": {"unexpected": "object"},
1930 "is_active": "yes",
1931 });
1932 let billing = from_billing_json(&value);
1933 assert_eq!(billing.total_credits_left, None);
1934 assert_eq!(billing.is_active, None);
1935 }
1936
1937 #[test]
1938 fn parse_billing_info_treats_non_object_json_as_default() {
1939 for body in [
1940 b"null".as_slice(),
1941 b"[]".as_slice(),
1942 br#""hello""#.as_slice(),
1943 ] {
1944 assert_eq!(parse_billing_info(body).unwrap(), BillingInfo::default());
1945 }
1946 }
1947
1948 #[test]
1949 fn parse_billing_info_rejects_non_json_bytes() {
1950 let err = parse_billing_info(b"nope").unwrap_err();
1951 assert!(err.to_string().contains("invalid billing JSON"));
1952 }
1953
1954 #[test]
1955 fn from_billing_json_unions_feature_sources() {
1956 let accessible_only = serde_json::json!({
1957 "accessible_features": [{"name": "get_stems"}],
1958 });
1959 assert!(from_billing_json(&accessible_only).can_get_stems());
1960
1961 let plan_only = serde_json::json!({
1962 "plan": {"usage_plan_features": [{"name": "convert_audio"}]},
1963 });
1964 assert!(from_billing_json(&plan_only).can_convert_audio());
1965
1966 let both = serde_json::json!({
1967 "accessible_features": [{"name": "get_stems"}, {"name": ""}, {"other": "x"}],
1968 "plan": {"usage_plan_features": [{"name": "convert_audio"}]},
1969 });
1970 let billing = from_billing_json(&both);
1971 assert!(billing.can_get_stems());
1972 assert!(billing.can_convert_audio());
1973 assert_eq!(billing.features.len(), 2);
1975 }
1976
1977 #[test]
1978 fn aligned_lyrics_reads_words_and_lines() {
1979 let mut rules = auth_rules();
1980 let body = serde_json::json!({
1981 "aligned_words": [
1982 {"word": "hi", "success": true, "start_s": 0.5, "end_s": 0.9, "p_align": 0.99}
1983 ],
1984 "aligned_lyrics": [
1985 {"text": "hi", "start_s": 0.5, "end_s": 0.9, "section": "Verse 1",
1986 "words": [{"text": "hi", "start_s": 0.5, "end_s": 0.9}]}
1987 ],
1988 "hoot_cer": 0.2, "is_streamed": false
1989 })
1990 .to_string();
1991 rules.push(Rule::new("/aligned_lyrics/v2/", 200, body));
1992 let http = MockHttp::new(rules);
1993 let client = authed_client(&http);
1994
1995 let aligned = pollster::block_on(client.aligned_lyrics(&http, "clip-1")).unwrap();
1996 assert_eq!(aligned.words.len(), 1);
1997 assert_eq!(aligned.lines.len(), 1);
1998 assert_eq!(aligned.lines[0].section, "Verse 1");
1999 assert!(!aligned.is_empty());
2000 }
2001
2002 #[test]
2003 fn aligned_lyrics_empty_arrays_map_to_empty() {
2004 let mut rules = auth_rules();
2005 rules.push(Rule::new(
2006 "/aligned_lyrics/v2/",
2007 200,
2008 r#"{"aligned_words":[],"aligned_lyrics":[],"hoot_cer":1.0}"#.to_string(),
2009 ));
2010 let http = MockHttp::new(rules);
2011 let client = authed_client(&http);
2012
2013 let aligned = pollster::block_on(client.aligned_lyrics(&http, "instr")).unwrap();
2014 assert!(aligned.is_empty());
2015 }
2016
2017 #[test]
2018 fn aligned_lyrics_maps_404_to_empty() {
2019 let mut rules = auth_rules();
2020 rules.push(Rule::new(
2021 "/aligned_lyrics/v2/",
2022 404,
2023 "not found".to_string(),
2024 ));
2025 let http = MockHttp::new(rules);
2026 let client = authed_client(&http);
2027
2028 let aligned = pollster::block_on(client.aligned_lyrics(&http, "missing")).unwrap();
2029 assert!(aligned.is_empty());
2030 }
2031
2032 fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
2033 let auth = ClerkAuth::new("eyJtoken");
2034 pollster::block_on(auth.authenticate(http)).unwrap();
2035 SunoClient::new(auth, clock)
2036 }
2037
2038 fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
2039 let mut page = serde_json::json!({
2040 "has_more": next_cursor.is_some(),
2041 "clips": [{
2042 "id": id, "title": "Song", "status": "complete",
2043 "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
2044 "metadata": {"type": "gen"}
2045 }]
2046 });
2047 if let Some(cursor) = next_cursor {
2048 page["next_cursor"] = serde_json::json!(cursor);
2049 }
2050 page.to_string()
2051 }
2052
2053 #[test]
2054 fn list_clips_retries_a_rate_limited_page() {
2055 let http = ScriptedHttp::new().with_auth().route_seq(
2056 "/api/feed/v3",
2057 vec![Reply::status(429), Reply::json(&feed_body())],
2058 );
2059 let clock = RecordingClock::new();
2060 let client = scripted_client(&http, clock.clone());
2061
2062 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2063 assert_eq!(clips.len(), 1);
2064 assert!(complete);
2065 assert_eq!(http.count("/api/feed/v3"), 2);
2067 assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
2068 }
2069
2070 #[test]
2071 fn list_clips_honours_retry_after_on_a_throttled_page() {
2072 let http = ScriptedHttp::new().with_auth().route_seq(
2073 "/api/feed/v3",
2074 vec![
2075 Reply::status(429).with_retry_after(7),
2076 Reply::json(&feed_body()),
2077 ],
2078 );
2079 let clock = RecordingClock::new();
2080 let client = scripted_client(&http, clock.clone());
2081
2082 let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2083 assert_eq!(clips.len(), 1);
2084 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
2086 }
2087
2088 #[test]
2089 fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
2090 let http = ScriptedHttp::new().with_auth().route_seq(
2092 "/api/feed/v3",
2093 vec![
2094 Reply::json(&one_clip_page("a", Some("cur1"))),
2095 Reply::status(429),
2096 Reply::json(&one_clip_page("b", None)),
2097 ],
2098 );
2099 let clock = RecordingClock::new();
2100 let client = scripted_client(&http, clock.clone());
2101
2102 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2103 assert!(complete);
2104 assert_eq!(clips.len(), 2);
2105 let bodies = http.bodies();
2106 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
2107 assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
2108 let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
2111 let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
2112 assert_eq!(retried["cursor"], "cur1");
2113 assert_eq!(after_retry["cursor"], "cur1");
2114 }
2115
2116 #[test]
2117 fn list_clips_threads_the_cursor_across_pages() {
2118 let http = ScriptedHttp::new().with_auth().route_seq(
2119 "/api/feed/v3",
2120 vec![
2121 Reply::json(&one_clip_page("a", Some("cur1"))),
2122 Reply::json(&one_clip_page("b", None)),
2123 ],
2124 );
2125 let clock = RecordingClock::new();
2126 let client = scripted_client(&http, clock.clone());
2127
2128 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2129 assert!(complete);
2130 assert_eq!(clips.len(), 2);
2131 let bodies = http.bodies();
2132 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
2133 assert_eq!(feed_bodies.len(), 2);
2134 let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
2135 let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
2136 assert!(page1.get("cursor").is_none());
2138 assert_eq!(page2["cursor"], "cur1");
2139 }
2140
2141 #[test]
2142 fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
2143 let page = serde_json::json!({
2146 "has_more": true,
2147 "clips": [{
2148 "id": "a", "title": "Song", "status": "complete",
2149 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
2150 }]
2151 })
2152 .to_string();
2153 let http = ScriptedHttp::new()
2154 .with_auth()
2155 .route("/api/feed/v3", Reply::json(&page));
2156 let clock = RecordingClock::new();
2157 let client = scripted_client(&http, clock.clone());
2158
2159 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2160 assert!(!complete);
2161 assert_eq!(clips.len(), 1);
2162 assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
2163 }
2164
2165 #[test]
2166 fn list_clips_is_incomplete_when_has_more_is_missing() {
2167 let page = serde_json::json!({
2169 "clips": [{
2170 "id": "a", "title": "Song", "status": "complete",
2171 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
2172 }]
2173 })
2174 .to_string();
2175 let http = ScriptedHttp::new()
2176 .with_auth()
2177 .route("/api/feed/v3", Reply::json(&page));
2178 let clock = RecordingClock::new();
2179 let client = scripted_client(&http, clock.clone());
2180
2181 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2182 assert!(!complete);
2183 assert_eq!(clips.len(), 1);
2184 assert_eq!(http.count("/api/feed/v3"), 1);
2185 }
2186
2187 #[test]
2188 fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
2189 let http = ScriptedHttp::new().with_auth().route_seq(
2190 "/api/feed/v3",
2191 vec![
2192 Reply::json(&one_clip_page("a", Some("cur1"))),
2193 Reply::status(500),
2194 ],
2195 );
2196 let clock = RecordingClock::new();
2197 let client = scripted_client(&http, clock.clone());
2198
2199 let result = pollster::block_on(client.list_clips(&http, false, None));
2200 assert!(matches!(result, Err(Error::Api(_))));
2201 }
2202
2203 #[test]
2204 fn list_clips_is_complete_on_an_empty_drained_feed() {
2205 let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
2208 let http = ScriptedHttp::new()
2209 .with_auth()
2210 .route("/api/feed/v3", Reply::json(&page));
2211 let clock = RecordingClock::new();
2212 let client = scripted_client(&http, clock.clone());
2213
2214 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2215 assert!(complete);
2216 assert!(clips.is_empty());
2217 }
2218
2219 #[test]
2220 fn list_clips_liked_scope_sends_the_liked_filter() {
2221 let http = ScriptedHttp::new()
2222 .with_auth()
2223 .route("/api/feed/v3", Reply::json(&feed_body()));
2224 let clock = RecordingClock::new();
2225 let client = scripted_client(&http, clock.clone());
2226
2227 let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
2228 let bodies = http.bodies();
2229 let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
2230 let value: Value = serde_json::from_str(feed_body).unwrap();
2231 assert_eq!(value["filters"]["liked"], "True");
2232 assert_eq!(value["filters"]["trashed"], "False");
2233 }
2234
2235 #[test]
2236 fn list_clips_does_not_pace_an_unthrottled_walk() {
2237 let http = ScriptedHttp::new().with_auth().route_seq(
2238 "/api/feed/v3",
2239 vec![
2240 Reply::json(&one_clip_page("a", Some("cur1"))),
2241 Reply::json(&one_clip_page("e", None)),
2242 ],
2243 );
2244 let clock = RecordingClock::new();
2245 let client = scripted_client(&http, clock.clone());
2246
2247 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2248 assert!(complete);
2249 assert_eq!(clips.len(), 2);
2250 assert_eq!(http.count("/api/feed/v3"), 2);
2251 assert!(clock.sleeps().is_empty());
2253 }
2254
2255 #[test]
2256 fn list_clips_slows_its_pace_after_a_throttled_page() {
2257 let http = ScriptedHttp::new().with_auth().route_seq(
2258 "/api/feed/v3",
2259 vec![
2260 Reply::status(429),
2261 Reply::json(&one_clip_page("a", Some("cur1"))),
2262 Reply::json(&one_clip_page("e", None)),
2263 ],
2264 );
2265 let clock = RecordingClock::new();
2266 let client = scripted_client(&http, clock.clone());
2267
2268 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2269 assert!(complete);
2270 assert_eq!(clips.len(), 2);
2271 assert_eq!(
2274 clock.sleeps(),
2275 vec![Duration::from_secs(5), Duration::from_secs(1)]
2276 );
2277 }
2278
2279 #[test]
2280 fn list_clips_gives_up_after_max_retries() {
2281 let http = ScriptedHttp::new()
2282 .with_auth()
2283 .route("/api/feed/v3", Reply::status(429));
2284 let clock = RecordingClock::new();
2285 let client = scripted_client(&http, clock.clone());
2286
2287 let result = pollster::block_on(client.list_clips(&http, false, None));
2288 assert!(matches!(result, Err(Error::RateLimited { .. })));
2289 let budget = crate::consts::API_MAX_RETRIES as usize;
2290 assert_eq!(clock.sleeps().len(), budget);
2291 assert_eq!(http.count("/api/feed/v3"), budget + 1);
2292 }
2293
2294 #[test]
2295 fn parse_clip_accepts_bare_and_wrapped_shapes() {
2296 let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
2297 assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
2298
2299 let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
2300 assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
2301
2302 let missing = serde_json::json!({"detail": "not found"}).to_string();
2303 assert!(parse_clip(missing.as_bytes()).is_none());
2304 }
2305
2306 #[test]
2307 fn get_clip_uses_the_dedicated_endpoint() {
2308 let clip_body = serde_json::json!({
2309 "id": "z", "title": "Zed", "status": "complete",
2310 "audio_url": "https://cdn1.suno.ai/z.mp3",
2311 "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
2312 })
2313 .to_string();
2314 let mut rules = auth_rules();
2315 rules.push(Rule::new("/api/clip/", 200, clip_body));
2316 let http = MockHttp::new(rules);
2317 let client = authed_client(&http);
2318
2319 let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
2320 assert_eq!(clip.id, "z");
2321 assert_eq!(clip.title, "Zed");
2322 assert_eq!(clip.tags, "jazz");
2323 }
2324
2325 #[test]
2326 fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
2327 let mut rules = auth_rules();
2328 rules.push(Rule::new(
2329 "/api/clip/",
2330 404,
2331 r#"{"detail": "not found"}"#.to_string(),
2332 ));
2333 rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
2334 let http = MockHttp::new(rules);
2335 let client = authed_client(&http);
2336
2337 let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
2338 assert_eq!(clip.id, "a");
2339 assert_eq!(clip.tags, "rock");
2340 }
2341
2342 #[test]
2343 fn request_wav_accepts_a_2xx_status() {
2344 let mut rules = auth_rules();
2345 rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
2346 let http = MockHttp::new(rules);
2347 let client = authed_client(&http);
2348
2349 assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
2350 }
2351
2352 #[test]
2353 fn wav_url_reads_the_ready_url() {
2354 let mut rules = auth_rules();
2355 rules.push(Rule::new(
2356 "/wav_file/",
2357 200,
2358 r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
2359 ));
2360 let http = MockHttp::new(rules);
2361 let client = authed_client(&http);
2362
2363 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
2364 assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
2365 }
2366
2367 #[test]
2368 fn wav_url_is_none_until_the_render_is_ready() {
2369 let mut rules = auth_rules();
2370 rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
2371 let http = MockHttp::new(rules);
2372 let client = authed_client(&http);
2373
2374 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
2375 assert_eq!(url, None);
2376 }
2377
2378 #[test]
2379 fn wav_url_404_maps_to_none() {
2380 let mut rules = auth_rules();
2384 rules.push(Rule::new(
2385 "/wav_file/",
2386 404,
2387 r#"{"detail": "Not found."}"#.to_string(),
2388 ));
2389 let http = MockHttp::new(rules);
2390 let client = authed_client(&http);
2391
2392 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
2393 assert_eq!(url, None);
2394 }
2395
2396 #[test]
2397 fn get_clips_by_ids_keeps_infill_and_upload_ancestors() {
2398 let p1 = serde_json::json!({
2402 "id": "p1", "title": "Infill Ancestor", "status": "complete",
2403 "metadata": {"type": "gen", "task": "infill"}
2404 })
2405 .to_string();
2406 let p2 = serde_json::json!({
2407 "id": "p2", "title": "Uploaded Root", "status": "complete",
2408 "metadata": {"type": "upload"}
2409 })
2410 .to_string();
2411 let batch = format!(r#"{{"clips":[{p1},{p2}]}}"#);
2412 let mut rules = auth_rules();
2413 rules.push(Rule::new("get_songs_by_ids", 200, batch));
2414 rules.push(Rule::new("/api/clip/p1", 200, p1));
2415 rules.push(Rule::new("/api/clip/p2", 200, p2));
2416 let http = MockHttp::new(rules);
2417 let client = authed_client(&http);
2418
2419 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"], 4)).unwrap();
2420 assert_eq!(
2421 clips.len(),
2422 2,
2423 "infill and upload ancestors must not be filtered"
2424 );
2425 assert_eq!(clips[0].id, "p1");
2426 assert_eq!(clips[1].id, "p2");
2427 }
2428
2429 #[test]
2430 fn get_clips_by_ids_returns_a_trashed_clip() {
2431 let trashed = serde_json::json!({
2434 "id": "t1", "title": "Trashed Ancestor", "status": "complete",
2435 "is_trashed": true, "metadata": {"type": "gen"}
2436 })
2437 .to_string();
2438 let batch = format!(r#"{{"clips":[{trashed}]}}"#);
2439 let mut rules = auth_rules();
2440 rules.push(Rule::new("get_songs_by_ids", 200, batch));
2441 rules.push(Rule::new("/api/clip/t1", 200, trashed));
2442 let http = MockHttp::new(rules);
2443 let client = authed_client(&http);
2444
2445 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"], 4)).unwrap();
2446 assert_eq!(clips.len(), 1);
2447 assert_eq!(clips[0].id, "t1");
2448 assert!(clips[0].is_trashed);
2449 }
2450
2451 #[test]
2452 fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
2453 let only = serde_json::json!({
2454 "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
2455 })
2456 .to_string();
2457 let batch = format!(r#"{{"clips":[{only}]}}"#);
2460 let http = ScriptedHttp::new()
2461 .with_auth()
2462 .route("get_songs_by_ids", Reply::json(&batch))
2463 .route("/api/clip/gone", Reply::status(404));
2464 let client = scripted_client(&http, RecordingClock::new());
2465
2466 let clips =
2467 pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"], 4))
2468 .unwrap();
2469 assert_eq!(clips.len(), 1, "the 404 id is skipped");
2470 assert_eq!(clips[0].id, "only");
2471 assert_eq!(
2474 http.count("get_songs_by_ids"),
2475 1,
2476 "one batch call for both ids"
2477 );
2478 assert_eq!(http.count("/api/clip/only"), 0);
2479 assert_eq!(http.count("/api/clip/gone"), 1);
2480 }
2481
2482 #[test]
2483 fn get_clips_by_ids_matches_serial_results_and_keeps_order_when_concurrent() {
2484 let a = serde_json::json!({
2488 "id": "a", "title": "A", "status": "complete", "metadata": {"type": "gen"}
2489 })
2490 .to_string();
2491 let b = serde_json::json!({
2492 "id": "b", "title": "B", "status": "complete", "metadata": {"type": "gen"}
2493 })
2494 .to_string();
2495 let c = serde_json::json!({
2496 "id": "c", "title": "C", "status": "complete", "metadata": {"type": "gen"}
2497 })
2498 .to_string();
2499 let http = ScriptedHttp::new()
2500 .with_auth()
2501 .route("/api/clip/a", Reply::json(&a))
2502 .route("/api/clip/b", Reply::json(&b))
2503 .route("/api/clip/c", Reply::json(&c));
2504 let client = scripted_client(&http, RecordingClock::new());
2505 let ids = ["b", "a", "c", "a"];
2506
2507 let serial = pollster::block_on(client.get_clips_by_ids(&http, &ids, 1)).unwrap();
2508 let concurrent = pollster::block_on(client.get_clips_by_ids(&http, &ids, 4)).unwrap();
2509
2510 let serial_ids: Vec<&str> = serial.iter().map(|clip| clip.id.as_str()).collect();
2511 let concurrent_ids: Vec<&str> = concurrent.iter().map(|clip| clip.id.as_str()).collect();
2512 assert_eq!(serial_ids, vec!["b", "a", "c"]);
2513 assert_eq!(concurrent_ids, serial_ids);
2514 }
2515
2516 fn clip_body(id: &str) -> String {
2518 format!(r#"{{"id":"{id}","title":"T","status":"complete","metadata":{{"type":"gen"}}}}"#)
2519 }
2520
2521 #[test]
2522 fn get_songs_by_ids_maps_the_batch_body_matched_by_id_in_input_order() {
2523 let batch = format!(
2526 r#"{{"clips":[{},{},{}]}}"#,
2527 clip_body("c"),
2528 clip_body("a"),
2529 clip_body("b")
2530 );
2531 let http = ScriptedHttp::new()
2532 .with_auth()
2533 .route("get_songs_by_ids", Reply::json(&batch));
2534 let client = scripted_client(&http, RecordingClock::new());
2535
2536 let clips =
2537 pollster::block_on(client.get_songs_by_ids(&http, &["a", "b", "c", "a"])).unwrap();
2538 let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2539 assert_eq!(ids, vec!["a", "b", "c"], "input order, not response order");
2540 assert_eq!(http.count("get_songs_by_ids"), 1, "one chunk, one request");
2541 }
2542
2543 #[test]
2544 fn get_songs_by_ids_drops_clips_that_were_not_requested() {
2545 let batch = format!(r#"{{"clips":[{},{}]}}"#, clip_body("a"), clip_body("x"));
2547 let http = ScriptedHttp::new()
2548 .with_auth()
2549 .route("get_songs_by_ids", Reply::json(&batch));
2550 let client = scripted_client(&http, RecordingClock::new());
2551
2552 let clips = pollster::block_on(client.get_songs_by_ids(&http, &["a"])).unwrap();
2553 let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2554 assert_eq!(ids, vec!["a"], "an unrequested id is dropped");
2555 }
2556
2557 #[test]
2558 fn get_songs_by_ids_chunks_ids_beyond_the_chunk_size() {
2559 let ids: Vec<String> = (0..21).map(|i| format!("id-{i:02}")).collect();
2562 let body = |slice: &[String]| {
2563 let clips: Vec<String> = slice.iter().map(|id| clip_body(id)).collect();
2564 format!(r#"{{"clips":[{}]}}"#, clips.join(","))
2565 };
2566 let http = ScriptedHttp::new().with_auth().route_seq(
2567 "get_songs_by_ids",
2568 vec![
2569 Reply::json(&body(&ids[..20])),
2570 Reply::json(&body(&ids[20..])),
2571 ],
2572 );
2573 let client = scripted_client(&http, RecordingClock::new());
2574 let refs: Vec<&str> = ids.iter().map(String::as_str).collect();
2575
2576 let clips = pollster::block_on(client.get_songs_by_ids(&http, &refs)).unwrap();
2577 let got: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2578 assert_eq!(got, refs, "all 21 ids returned in input order");
2579 assert_eq!(
2580 http.count("get_songs_by_ids"),
2581 2,
2582 "two chunks -> two requests"
2583 );
2584 let batch_calls: Vec<String> = http
2585 .calls()
2586 .into_iter()
2587 .filter(|url| url.contains("get_songs_by_ids"))
2588 .collect();
2589 assert_eq!(
2590 batch_calls[0].matches("ids=").count(),
2591 20,
2592 "first chunk of 20"
2593 );
2594 assert_eq!(
2595 batch_calls[1].matches("ids=").count(),
2596 1,
2597 "second chunk of 1"
2598 );
2599 }
2600
2601 #[test]
2602 fn get_clips_by_ids_batch_first_does_not_fetch_per_id_when_batch_is_complete() {
2603 let batch = format!(r#"{{"clips":[{},{}]}}"#, clip_body("a"), clip_body("b"));
2605 let http = ScriptedHttp::new()
2606 .with_auth()
2607 .route("get_songs_by_ids", Reply::json(&batch))
2608 .route("/api/clip/a", Reply::json(&clip_body("a")))
2609 .route("/api/clip/b", Reply::json(&clip_body("b")));
2610 let client = scripted_client(&http, RecordingClock::new());
2611
2612 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4)).unwrap();
2613 let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2614 assert_eq!(ids, vec!["a", "b"]);
2615 assert_eq!(http.count("get_songs_by_ids"), 1);
2616 assert_eq!(
2617 http.count("/api/clip/"),
2618 0,
2619 "a complete batch needs no per-id fallback"
2620 );
2621 }
2622
2623 #[test]
2624 fn get_clips_by_ids_fills_ids_the_batch_omits_via_per_id() {
2625 let batch = format!(r#"{{"clips":[{}]}}"#, clip_body("a"));
2627 let http = ScriptedHttp::new()
2628 .with_auth()
2629 .route("get_songs_by_ids", Reply::json(&batch))
2630 .route("/api/clip/b", Reply::json(&clip_body("b")));
2631 let client = scripted_client(&http, RecordingClock::new());
2632
2633 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4)).unwrap();
2634 let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2635 assert_eq!(ids, vec!["a", "b"], "omitted id is filled, order preserved");
2636 assert_eq!(http.count("/api/clip/a"), 0, "a came from the batch");
2637 assert_eq!(http.count("/api/clip/b"), 1, "b was filled per-id");
2638 }
2639
2640 #[test]
2641 fn get_clips_by_ids_falls_back_to_per_id_on_a_malformed_batch_body() {
2642 let http = ScriptedHttp::new()
2645 .with_auth()
2646 .route("get_songs_by_ids", Reply::json("not-json{"))
2647 .route("/api/clip/a", Reply::json(&clip_body("a")))
2648 .route("/api/clip/b", Reply::json(&clip_body("b")));
2649 let client = scripted_client(&http, RecordingClock::new());
2650
2651 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4)).unwrap();
2652 let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2653 assert_eq!(ids, vec!["a", "b"]);
2654 assert_eq!(http.count("/api/clip/a"), 1);
2655 assert_eq!(http.count("/api/clip/b"), 1);
2656 }
2657
2658 #[test]
2659 fn get_clips_by_ids_propagates_a_batch_rate_limit_without_per_id_fan_out() {
2660 let http = ScriptedHttp::new()
2663 .with_auth()
2664 .route("get_songs_by_ids", Reply::status(429))
2665 .route("/api/clip/a", Reply::json(&clip_body("a")))
2666 .route("/api/clip/b", Reply::json(&clip_body("b")));
2667 let client = scripted_client(&http, RecordingClock::new());
2668
2669 let result = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4));
2670 assert!(
2671 matches!(result, Err(Error::RateLimited { .. })),
2672 "an exhausted 429 propagates"
2673 );
2674 assert_eq!(
2675 http.count("/api/clip/"),
2676 0,
2677 "no per-id fan-out on rate-limit exhaustion"
2678 );
2679 }
2680
2681 #[test]
2682 fn concurrent_reads_share_aggregate_pacing_after_first_rate_limit() {
2683 const EXPECTED_SPAN: Duration = Duration::from_secs(4);
2688 const TOLERANCE: Duration = Duration::from_millis(50);
2689 let ids = ["a", "b", "c", "d"];
2690 let a =
2691 serde_json::json!({"id":"a","title":"A","status":"complete","metadata":{"type":"gen"}})
2692 .to_string();
2693 let b =
2694 serde_json::json!({"id":"b","title":"B","status":"complete","metadata":{"type":"gen"}})
2695 .to_string();
2696 let c =
2697 serde_json::json!({"id":"c","title":"C","status":"complete","metadata":{"type":"gen"}})
2698 .to_string();
2699 let d =
2700 serde_json::json!({"id":"d","title":"D","status":"complete","metadata":{"type":"gen"}})
2701 .to_string();
2702 let http = ScriptedHttp::new()
2703 .with_auth()
2704 .route_seq(
2705 "/api/feed/v3",
2706 vec![
2707 Reply::status(429),
2708 Reply::json(&one_clip_page("seed", None)),
2709 ],
2710 )
2711 .route("get_songs_by_ids", Reply::json(r#"{"clips":[]}"#))
2712 .route("/api/clip/a", Reply::json(&a))
2713 .route("/api/clip/b", Reply::json(&b))
2714 .route("/api/clip/c", Reply::json(&c))
2715 .route("/api/clip/d", Reply::json(&d));
2716 let clock = RecordingClock::new();
2717 let client = scripted_client(&http, clock.clone());
2718 pollster::block_on(client.list_clips(&http, false, Some(1))).unwrap();
2719 let before = clock.sleeps().len();
2720
2721 let clips = pollster::block_on(client.get_clips_by_ids(&http, &ids, ids.len())).unwrap();
2722 assert_eq!(clips.len(), ids.len());
2723 let sleeps = clock.sleeps();
2724 let paced = &sleeps[before..];
2725 assert_eq!(
2726 paced.len(),
2727 ids.len() + 1,
2728 "one batch call plus four per-id"
2729 );
2730 let min = paced.iter().copied().min().unwrap();
2731 let max = paced.iter().copied().max().unwrap();
2732 let span = max.saturating_sub(min);
2733 assert!(span >= EXPECTED_SPAN.saturating_sub(TOLERANCE));
2738 assert!(span <= EXPECTED_SPAN + TOLERANCE);
2739 }
2740
2741 #[test]
2742 fn get_clip_parent_reads_the_parent_clip() {
2743 let parent = serde_json::json!({
2744 "id": "par", "title": "Ancestor", "status": "complete",
2745 "metadata": {"type": "gen"}
2746 })
2747 .to_string();
2748 let mut rules = auth_rules();
2749 rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
2750 let http = MockHttp::new(rules);
2751 let client = authed_client(&http);
2752
2753 let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
2754 assert_eq!(clip.unwrap().id, "par");
2755 }
2756
2757 #[test]
2758 fn get_clip_parent_is_none_for_a_root() {
2759 let mut rules = auth_rules();
2760 rules.push(Rule::new(
2761 "/api/clips/parent",
2762 404,
2763 r#"{"detail": "no parent"}"#.to_string(),
2764 ));
2765 let http = MockHttp::new(rules);
2766 let client = authed_client(&http);
2767
2768 let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
2769 assert!(clip.is_none());
2770 }
2771
2772 #[test]
2773 fn get_clip_parent_is_none_for_a_200_no_id_root() {
2774 for body in [
2779 r#"{"is_public": false}"#,
2780 r#"{"clip": {"is_public": false}}"#,
2781 ] {
2782 let mut rules = auth_rules();
2783 rules.push(Rule::new("/api/clips/parent", 200, body.to_string()));
2784 let http = MockHttp::new(rules);
2785 let client = authed_client(&http);
2786
2787 let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
2788 assert!(clip.is_none(), "200-no-id body {body:?} must map to None");
2789 }
2790 }
2791
2792 #[test]
2793 fn get_clip_parent_reads_the_reduced_user_prefixed_shape() {
2794 let parent = serde_json::json!({
2798 "id": "00000000-0000-4000-8000-000000000020",
2799 "title": "Track 2",
2800 "is_public": false,
2801 "user_display_name": "Example Artist 4",
2802 "user_handle": "example-artist-1",
2803 "user_avatar_image_url": "https://cdn1.suno.ai/avatar.jpg"
2804 })
2805 .to_string();
2806 let mut rules = auth_rules();
2807 rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
2808 let http = MockHttp::new(rules);
2809 let client = authed_client(&http);
2810
2811 let clip = pollster::block_on(client.get_clip_parent(&http, "child"))
2812 .unwrap()
2813 .expect("a parent clip with an id");
2814 assert_eq!(clip.id, "00000000-0000-4000-8000-000000000020");
2815 assert_eq!(clip.display_name, "Example Artist 4");
2816 assert_eq!(clip.handle, "example-artist-1");
2817 assert_eq!(clip.avatar_image_url, "https://cdn1.suno.ai/avatar.jpg");
2818 }
2819
2820 #[test]
2821 fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
2822 for status in [500u16, 503] {
2826 let mut rules = auth_rules();
2827 rules.push(Rule::new(
2828 "/api/clips/parent",
2829 status,
2830 r#"{"detail": "server error"}"#.to_string(),
2831 ));
2832 let http = MockHttp::new(rules);
2833 let client = authed_client(&http);
2834
2835 let result = pollster::block_on(client.get_clip_parent(&http, "child"));
2836 assert!(
2837 matches!(result, Err(Error::Api(_))),
2838 "status {status} must propagate as an error, not Ok(None)"
2839 );
2840 }
2841 }
2842
2843 #[test]
2844 fn get_playlists_maps_entries_and_skips_missing_ids() {
2845 let page1 = serde_json::json!({
2846 "playlists": [
2847 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
2848 {"id": "", "name": "No Id", "num_total_results": 3},
2849 {"name": "Also No Id"}
2850 ]
2851 })
2852 .to_string();
2853 let mut rules = auth_rules();
2854 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
2856 rules.push(Rule::new(
2857 "/api/playlist/me?page=2",
2858 200,
2859 r#"{"playlists": []}"#.to_string(),
2860 ));
2861 let http = MockHttp::new(rules);
2862 let client = authed_client(&http);
2863
2864 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
2865 assert_eq!(playlists.len(), 1, "entries without an id are dropped");
2866 assert_eq!(
2867 playlists[0],
2868 Playlist {
2869 id: "pl1".to_owned(),
2870 name: "Road Trip".to_owned(),
2871 num_clips: 12,
2872 }
2873 );
2874 }
2875
2876 #[test]
2877 fn get_playlists_defaults_a_missing_name_to_untitled() {
2878 let page1 = serde_json::json!({
2879 "playlists": [{"id": "pl9", "num_total_results": 1}]
2880 })
2881 .to_string();
2882 let mut rules = auth_rules();
2883 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
2884 rules.push(Rule::new(
2885 "/api/playlist/me?page=2",
2886 200,
2887 r#"{"playlists": []}"#.to_string(),
2888 ));
2889 let http = MockHttp::new(rules);
2890 let client = authed_client(&http);
2891
2892 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
2893 assert_eq!(playlists[0].name, "Untitled");
2894 }
2895
2896 #[test]
2897 fn get_playlist_clips_preserves_order_and_unwraps_clip() {
2898 let body = serde_json::json!({
2901 "num_total_results": 2,
2902 "playlist_clips": [
2903 {"clip": {
2904 "id": "second", "title": "Second", "status": "complete",
2905 "metadata": {"duration": 60.0, "type": "gen"}
2906 }},
2907 {"clip": {
2908 "id": "first", "title": "First", "status": "complete",
2909 "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
2910 }}
2911 ]
2912 })
2913 .to_string();
2914 let mut rules = auth_rules();
2915 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
2916 let http = MockHttp::new(rules);
2917 let client = authed_client(&http);
2918
2919 let (clips, complete) =
2920 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
2921 assert_eq!(clips.len(), 2, "an infill member is not filtered out");
2922 assert_eq!(clips[0].id, "second");
2923 assert_eq!(clips[1].id, "first");
2924 assert!(
2925 complete,
2926 "returned == num_total_results is fully enumerated"
2927 );
2928 }
2929
2930 #[test]
2931 fn get_playlist_clips_short_page_is_not_complete() {
2932 let body = serde_json::json!({
2934 "num_total_results": 5,
2935 "playlist_clips": [
2936 {"clip": {
2937 "id": "only", "title": "Only", "status": "complete",
2938 "metadata": {"duration": 60.0, "type": "gen"}
2939 }}
2940 ]
2941 })
2942 .to_string();
2943 let mut rules = auth_rules();
2944 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
2945 let http = MockHttp::new(rules);
2946 let client = authed_client(&http);
2947
2948 let (clips, complete) =
2949 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
2950 assert_eq!(clips.len(), 1);
2951 assert!(!complete, "a short page is not fully enumerated");
2952 }
2953
2954 #[test]
2955 fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
2956 let mut rules = auth_rules();
2957 rules.push(Rule::new(
2958 "/api/playlist/empty/",
2959 200,
2960 r#"{"num_total_results": 0, "playlist_clips": []}"#.to_string(),
2961 ));
2962 let http = MockHttp::new(rules);
2963 let client = authed_client(&http);
2964
2965 let (clips, complete) =
2966 pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
2967 assert!(clips.is_empty());
2968 assert!(
2969 complete,
2970 "an empty playlist reporting zero total is complete"
2971 );
2972 }
2973
2974 #[test]
2975 fn get_playlist_clips_missing_total_is_not_complete() {
2976 let mut rules = auth_rules();
2980 rules.push(Rule::new(
2981 "/api/playlist/pl1/",
2982 200,
2983 r#"{"playlist_clips": []}"#.to_string(),
2984 ));
2985 let http = MockHttp::new(rules);
2986 let client = authed_client(&http);
2987
2988 let (clips, complete) =
2989 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
2990 assert!(clips.is_empty());
2991 assert!(!complete, "a missing total is never fully enumerated");
2992 }
2993
2994 #[test]
2995 fn get_playlist_clips_dropped_member_disarms_authority() {
2996 let missing_id = serde_json::json!({
3001 "num_total_results": 2,
3002 "playlist_clips": [
3003 {"clip": {
3004 "id": "a", "title": "A", "status": "complete",
3005 "metadata": {"duration": 60.0, "type": "gen"}
3006 }},
3007 {"clip": {
3008 "title": "No Id", "status": "complete",
3009 "metadata": {"duration": 30.0, "type": "gen"}
3010 }}
3011 ]
3012 })
3013 .to_string();
3014 let empty_id = serde_json::json!({
3015 "num_total_results": 2,
3016 "playlist_clips": [
3017 {"clip": {
3018 "id": "a", "title": "A", "status": "complete",
3019 "metadata": {"duration": 60.0, "type": "gen"}
3020 }},
3021 {"clip": {
3022 "id": "", "title": "Empty Id", "status": "complete",
3023 "metadata": {"duration": 30.0, "type": "gen"}
3024 }}
3025 ]
3026 })
3027 .to_string();
3028 for body in [missing_id, empty_id] {
3029 let mut rules = auth_rules();
3030 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3031 let http = MockHttp::new(rules);
3032 let client = authed_client(&http);
3033
3034 let (clips, complete) =
3035 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3036 assert_eq!(clips.len(), 1, "the member with no id is dropped");
3037 assert!(
3038 !complete,
3039 "a dropped member disarms authority even when raw_len == total"
3040 );
3041 }
3042 }
3043
3044 #[test]
3045 fn get_playlist_clips_over_count_is_not_complete() {
3046 let body = serde_json::json!({
3051 "num_total_results": 2,
3052 "playlist_clips": [
3053 {"clip": {
3054 "id": "a", "title": "A", "status": "complete",
3055 "metadata": {"duration": 60.0, "type": "gen"}
3056 }},
3057 {"clip": {
3058 "id": "b", "title": "B", "status": "complete",
3059 "metadata": {"duration": 30.0, "type": "gen"}
3060 }},
3061 {"clip": {
3062 "id": "", "title": "Empty Id", "status": "complete",
3063 "metadata": {"duration": 45.0, "type": "gen"}
3064 }}
3065 ]
3066 })
3067 .to_string();
3068 let mut rules = auth_rules();
3069 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3070 let http = MockHttp::new(rules);
3071 let client = authed_client(&http);
3072
3073 let (clips, complete) =
3074 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3075 assert_eq!(clips.len(), 2, "the empty-id member is dropped");
3076 assert!(
3077 !complete,
3078 "raw_len (3) diverging from the total (2) is not authoritative"
3079 );
3080 }
3081
3082 #[test]
3083 fn get_playlist_clips_ignores_song_count() {
3084 let body = serde_json::json!({
3088 "num_total_results": 1,
3089 "song_count": 0,
3090 "playlist_clips": [
3091 {"clip": {
3092 "id": "only", "title": "Only", "status": "complete",
3093 "metadata": {"duration": 60.0, "type": "gen"}
3094 }}
3095 ]
3096 })
3097 .to_string();
3098 let mut rules = auth_rules();
3099 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3100 let http = MockHttp::new(rules);
3101 let client = authed_client(&http);
3102
3103 let (clips, complete) =
3104 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3105 assert_eq!(clips.len(), 1);
3106 assert!(
3107 complete,
3108 "completeness uses num_total_results, not song_count"
3109 );
3110 }
3111
3112 #[test]
3113 fn get_playlists_num_clips_ignores_song_count() {
3114 let page1 = serde_json::json!({
3117 "playlists": [
3118 {"id": "pl1", "name": "Road Trip", "num_total_results": 15, "song_count": 0}
3119 ]
3120 })
3121 .to_string();
3122 let mut rules = auth_rules();
3123 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
3124 rules.push(Rule::new(
3125 "/api/playlist/me?page=2",
3126 200,
3127 r#"{"playlists": []}"#.to_string(),
3128 ));
3129 let http = MockHttp::new(rules);
3130 let client = authed_client(&http);
3131
3132 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
3133 assert_eq!(
3134 playlists[0].num_clips, 15,
3135 "num_clips reads num_total_results, not song_count"
3136 );
3137 }
3138
3139 #[test]
3140 fn get_playlists_dedupes_a_page_ignoring_server() {
3141 let same_body = serde_json::json!({
3146 "playlists": [
3147 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
3148 {"id": "pl2", "name": "Chill", "num_total_results": 7}
3149 ]
3150 })
3151 .to_string();
3152 let mut rules = auth_rules();
3153 rules.push(Rule::new("/api/playlist/me", 200, same_body));
3154 let http = MockHttp::new(rules);
3155 let client = authed_client(&http);
3156
3157 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
3158 assert_eq!(
3159 playlists.len(),
3160 2,
3161 "duplicates from a page-ignoring server are collapsed"
3162 );
3163 assert_eq!(playlists[0].id, "pl1");
3164 assert_eq!(playlists[1].id, "pl2");
3165 }
3166
3167 #[test]
3168 fn get_playlist_clips_preserves_array_order_over_created_at() {
3169 let body = serde_json::json!({
3173 "num_total_results": 3,
3174 "playlist_clips": [
3175 {"clip": {
3176 "id": "a", "title": "A", "status": "complete",
3177 "metadata": {"duration": 60.0, "type": "gen"}
3178 }, "relative_index": 1.0, "created_at": "2026-06-08T00:00:00.000Z"},
3179 {"clip": {
3180 "id": "b", "title": "B", "status": "complete",
3181 "metadata": {"duration": 30.0, "type": "gen"}
3182 }, "relative_index": 2.0, "created_at": "2026-01-11T00:00:00.000Z"},
3183 {"clip": {
3184 "id": "c", "title": "C", "status": "complete",
3185 "metadata": {"duration": 45.0, "type": "gen"}
3186 }, "relative_index": 3.0, "created_at": "2026-05-15T00:00:00.000Z"}
3187 ]
3188 })
3189 .to_string();
3190 let mut rules = auth_rules();
3191 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3192 let http = MockHttp::new(rules);
3193 let client = authed_client(&http);
3194
3195 let (clips, complete) =
3196 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3197 assert_eq!(
3198 clips.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(),
3199 ["a", "b", "c"],
3200 "array order is preserved despite non-monotonic created_at"
3201 );
3202 assert!(complete, "three intact members equal the declared total");
3203 }
3204
3205 fn stem_page(stems: &[(&str, &str, &str)]) -> String {
3208 let entries: Vec<Value> = stems
3209 .iter()
3210 .map(|(id, label, url)| {
3211 serde_json::json!({
3212 "id": id,
3213 "title": format!("My Song ({label})"),
3214 "status": "complete",
3215 "audio_url": url,
3216 })
3217 })
3218 .collect();
3219 serde_json::json!({ "stems": entries }).to_string()
3220 }
3221
3222 fn stem_pages(pages: u32) -> String {
3224 serde_json::json!({ "pages": pages }).to_string()
3225 }
3226
3227 #[test]
3228 fn list_stems_drains_all_declared_pages_and_is_authoritative() {
3229 let http = ScriptedHttp::new()
3232 .with_auth()
3233 .route("stems/pages", Reply::json(&stem_pages(2)))
3234 .route(
3235 "stems?page=0",
3236 Reply::json(&stem_page(&[
3237 ("s1", "Vocals", "https://cdn1.suno.ai/s1.mp3"),
3238 ("s2", "Drums", "https://cdn1.suno.ai/s2.mp3"),
3239 ])),
3240 )
3241 .route(
3242 "stems?page=1",
3243 Reply::json(&stem_page(&[("s3", "Bass", "https://cdn1.suno.ai/s3.mp3")])),
3244 );
3245 let client = scripted_client(&http, RecordingClock::new());
3246
3247 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3248 assert_eq!(stems.len(), 3);
3249 assert_eq!(stems[0].id, "s1");
3250 assert_eq!(stems[0].label, "Vocals");
3251 assert_eq!(stems[0].url, "https://cdn1.suno.ai/s1.mp3");
3252 assert_eq!(stems[2].label, "Bass");
3253 assert!(
3254 complete,
3255 "a fully drained listing that returned stems is authoritative"
3256 );
3257 }
3258
3259 #[test]
3260 fn list_stems_zero_pages_is_indeterminate_never_empty() {
3261 let http = ScriptedHttp::new()
3264 .with_auth()
3265 .route("stems/pages", Reply::json(&stem_pages(0)));
3266 let client = scripted_client(&http, RecordingClock::new());
3267
3268 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3269 assert!(stems.is_empty());
3270 assert!(
3271 !complete,
3272 "an empty listing is indeterminate, so existing stems are kept"
3273 );
3274 }
3275
3276 #[test]
3277 fn list_stems_missing_page_count_is_indeterminate() {
3278 for status in [400u16, 404] {
3281 let http = ScriptedHttp::new()
3282 .with_auth()
3283 .route("stems/pages", Reply::status(status));
3284 let client = scripted_client(&http, RecordingClock::new());
3285 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3286 assert!(stems.is_empty(), "status {status}");
3287 assert!(!complete, "status {status} is indeterminate, not empty");
3288 }
3289 }
3290
3291 #[test]
3292 fn stem_page_count_5xx_with_invalid_page_body_is_not_no_stems() {
3293 let http = ScriptedHttp::new()
3297 .with_auth()
3298 .route("stems/pages", Reply::with_body(500, "Invalid page"));
3299 let client = scripted_client(&http, RecordingClock::new());
3300
3301 let result = pollster::block_on(client.list_stems(&http, "clip1"));
3302 assert!(
3303 result.is_err(),
3304 "a 5xx is a transient error, never 'no stems'"
3305 );
3306 }
3307
3308 #[test]
3309 fn list_stems_page_error_mid_enumeration_propagates() {
3310 let http = ScriptedHttp::new()
3314 .with_auth()
3315 .route("stems/pages", Reply::json(&stem_pages(2)))
3316 .route(
3317 "stems?page=0",
3318 Reply::json(&stem_page(&[(
3319 "s1",
3320 "Vocals",
3321 "https://cdn1.suno.ai/s1.mp3",
3322 )])),
3323 )
3324 .route("stems?page=1", Reply::status(500));
3325 let client = scripted_client(&http, RecordingClock::new());
3326
3327 let result = pollster::block_on(client.list_stems(&http, "clip1"));
3328 assert!(result.is_err(), "a 5xx page is not a clean drain");
3329 }
3330
3331 #[test]
3332 fn list_stems_over_max_pages_is_truncated_never_authoritative() {
3333 let http = ScriptedHttp::new()
3338 .with_auth()
3339 .route("stems/pages", Reply::json(&stem_pages(MAX_PAGES + 1)))
3340 .route(
3341 "stems?page=",
3342 Reply::json(&stem_page(&[(
3343 "s1",
3344 "Vocals",
3345 "https://cdn1.suno.ai/s1.mp3",
3346 )])),
3347 );
3348 let client = scripted_client(&http, RecordingClock::new());
3349
3350 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3351 assert!(!stems.is_empty(), "the fetched pages still yield stems");
3352 assert!(
3353 !complete,
3354 "a listing declaring more than MAX_PAGES is truncated, never authoritative"
3355 );
3356 }
3357
3358 #[test]
3359 fn parse_stems_page_maps_full_clips_and_skips_idless() {
3360 let page = stem_page(&[("x", "Backing Vocals", "https://cdn1.suno.ai/x.mp3")]);
3363 let stems = parse_stems_page(page.as_bytes());
3364 assert_eq!(stems.len(), 1);
3365 assert_eq!(stems[0].id, "x");
3366 assert_eq!(stems[0].label, "Backing Vocals");
3367 assert_eq!(stems[0].url, "https://cdn1.suno.ai/x.mp3");
3368 let no_id = br#"{"stems": [{"title": "Ghost (Vocals)", "audio_url": "https://cdn1.suno.ai/g.mp3"}]}"#;
3370 assert!(parse_stems_page(no_id).is_empty());
3371 let no_url = br#"{"stems": [{"id": "y", "title": "Song (Bass)"}]}"#;
3374 let recovered = parse_stems_page(no_url);
3375 assert_eq!(recovered.len(), 1);
3376 assert_eq!(recovered[0].url, "https://cdn1.suno.ai/y.mp3");
3377 assert!(parse_stems_page(b"not json").is_empty());
3379 }
3380
3381 #[test]
3382 fn list_stems_labels_the_inferred_populated_page_from_the_stem_group() {
3383 let page = serde_json::json!({
3389 "stems": [{
3390 "id": "stem-bv",
3391 "title": "Track 30",
3392 "status": "complete",
3393 "audio_url": "https://cdn1.suno.ai/stem-bv.mp3",
3394 "metadata": {
3395 "stem_from_id": "source-074",
3396 "stem_task": "twelve",
3397 "stem_type_id": 91.0,
3398 "stem_type_group_name": "Backing_Vocals"
3399 }
3400 }]
3401 })
3402 .to_string();
3403 let http = ScriptedHttp::new()
3404 .with_auth()
3405 .route("stems/pages", Reply::json(&stem_pages(1)))
3406 .route("stems?page=0", Reply::json(&page));
3407 let client = scripted_client(&http, RecordingClock::new());
3408
3409 let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3410 assert_eq!(stems.len(), 1);
3411 assert_eq!(stems[0].id, "stem-bv");
3412 assert_eq!(
3413 stems[0].label, "Backing Vocals",
3414 "the underscore group name is normalised, not the empty title parenthetical"
3415 );
3416 assert_eq!(stems[0].url, "https://cdn1.suno.ai/stem-bv.mp3");
3417 assert!(
3418 complete,
3419 "a drained listing that returned a stem is authoritative"
3420 );
3421 }
3422
3423 #[test]
3424 fn stem_label_prefers_the_normalised_group_over_the_title() {
3425 let grouped = Clip {
3427 title: "Track 30".to_owned(),
3428 stem_type_group_name: "Backing_Vocals".to_owned(),
3429 ..Default::default()
3430 };
3431 assert_eq!(stem_label(&grouped), "Backing Vocals");
3432 let both = Clip {
3435 title: "My Song (Guitar)".to_owned(),
3436 stem_type_group_name: "Vocals".to_owned(),
3437 ..Default::default()
3438 };
3439 assert_eq!(stem_label(&both), "Vocals");
3440 let titled = Clip {
3442 title: "My Song (Drums)".to_owned(),
3443 ..Default::default()
3444 };
3445 assert_eq!(stem_label(&titled), "Drums");
3446 let bare = Clip {
3448 title: "Track 31".to_owned(),
3449 ..Default::default()
3450 };
3451 assert_eq!(stem_label(&bare), "");
3452 }
3453
3454 #[test]
3455 fn parse_stem_page_count_reads_pages_field() {
3456 assert_eq!(parse_stem_page_count(br#"{"pages": 12}"#), 12);
3457 assert_eq!(parse_stem_page_count(br#"{"pages": 0}"#), 0);
3458 assert_eq!(parse_stem_page_count(br#"{}"#), 0);
3460 assert_eq!(parse_stem_page_count(br#"{"pages": -1}"#), 0);
3461 assert_eq!(parse_stem_page_count(b"not json"), 0);
3462 }
3463
3464 #[test]
3465 fn stem_label_from_title_extracts_trailing_parenthetical() {
3466 assert_eq!(stem_label_from_title("My Song (Vocals)"), "Vocals");
3467 assert_eq!(
3468 stem_label_from_title("A (b) Song (Backing Vocals)"),
3469 "Backing Vocals"
3470 );
3471 assert_eq!(stem_label_from_title("My Song (Drums) "), "Drums");
3472 assert_eq!(stem_label_from_title("My Song"), "");
3474 assert_eq!(stem_label_from_title(""), "");
3475 }
3476
3477 #[test]
3478 fn post_allow_list_permits_only_feed_and_wav_render() {
3479 assert!(post_path_allowed(FEED_V3_PATH));
3480 assert!(post_path_allowed("/api/gen/abc123/convert_wav/"));
3481 assert!(!post_path_allowed("/api/gen/abc123/stem_task"));
3483 assert!(!post_path_allowed("/api/gen/abc123/separate"));
3484 assert!(!post_path_allowed("/api/gen/a/../evil/convert_wav/"));
3486 assert!(!post_path_allowed("/api/gen/a/b/convert_wav/"));
3487 assert!(!post_path_allowed("/api/clip/x/stems/pages"));
3489 assert!(!post_path_allowed("/api/clip/x/stems?page=0"));
3490 }
3491
3492 #[test]
3493 fn api_request_refuses_a_post_off_the_allow_list() {
3494 let http = MockHttp::new(auth_rules());
3497 let client = authed_client(&http);
3498 let err = pollster::block_on(client.api_request(
3499 &http,
3500 Method::Post,
3501 "/api/gen/x/stem_task",
3502 b"{}".to_vec(),
3503 ))
3504 .unwrap_err();
3505 assert!(matches!(err, Error::Refused(_)));
3506 }
3507}