1use std::collections::BTreeSet;
4
5use serde_json::Value;
6
7use crate::auth::ClerkAuth;
8use crate::backoff::{backoff_delay, retry_after};
9use crate::clock::Clock;
10use crate::consts::{
11 API_MAX_RETRIES, CLIP_PARENT_PATH, FEED_INITIAL_RATE, FEED_PAGE_SIZE, FEED_V3_PATH, MAX_PAGES,
12 PLAYLIST_ME_PATH, PLAYLIST_PATH, SUNO_API_BASE_URL,
13};
14use crate::error::{Error, Result};
15use crate::http::{Http, HttpRequest, Method};
16use crate::is_downloadable;
17use crate::limiter::{AdaptiveLimiter, retry_after_delay};
18use crate::model::Clip;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Playlist {
28 pub id: String,
30 pub name: String,
32 pub num_clips: u64,
34}
35
36pub struct SunoClient<C> {
45 auth: ClerkAuth,
46 clock: C,
47 limiter: AdaptiveLimiter,
48}
49
50impl<C: Clock> SunoClient<C> {
51 pub fn new(auth: ClerkAuth, clock: C) -> Self {
53 Self {
54 auth,
55 clock,
56 limiter: AdaptiveLimiter::new(FEED_INITIAL_RATE),
57 }
58 }
59
60 pub fn auth(&self) -> &ClerkAuth {
62 &self.auth
63 }
64
65 #[cfg(test)]
69 pub(crate) fn limiter_rate(&self) -> f64 {
70 self.limiter.rate()
71 }
72
73 pub async fn list_clips(
89 &mut self,
90 http: &impl Http,
91 liked: bool,
92 limit: Option<usize>,
93 ) -> Result<(Vec<Clip>, bool)> {
94 let mut clips = Vec::new();
95 let mut cursor: Option<String> = None;
96 let mut complete = false;
97 for _ in 0..MAX_PAGES {
98 let body = feed_v3_body(liked, cursor.as_deref());
99 let response = self
100 .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
101 .await?;
102 let (page_clips, has_more, next_cursor) = parse_feed_v3(&response)?;
103 clips.extend(page_clips);
104 match has_more {
105 Some(false) => {
106 complete = true;
107 break;
108 }
109 Some(true) => match next_cursor {
110 Some(next) => cursor = Some(next),
111 None => break,
112 },
113 None => break,
114 }
115 if limit.is_some_and(|n| clips.len() >= n) {
116 break;
117 }
118 }
119 if let Some(n) = limit {
120 clips.truncate(n);
121 }
122 Ok((clips, complete))
123 }
124
125 pub async fn get_clip(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
131 if let Some(clip) = self.try_get_clip(http, id).await? {
132 return Ok(clip);
133 }
134 self.find_in_feed(http, id).await
135 }
136
137 pub async fn request_wav(&mut self, http: &impl Http, id: &str) -> Result<()> {
139 let path = format!("/api/gen/{id}/convert_wav/");
140 self.api_request(http, Method::Post, &path, Vec::new())
141 .await?;
142 Ok(())
143 }
144
145 pub async fn wav_url(&mut self, http: &impl Http, id: &str) -> Result<Option<String>> {
147 let path = format!("/api/gen/{id}/wav_file/");
148 let body = self.api_get(http, &path).await?;
149 let data: Value = serde_json::from_slice(&body)
150 .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
151 Ok(data
152 .get("wav_file_url")
153 .and_then(Value::as_str)
154 .filter(|url| !url.is_empty())
155 .map(str::to_string))
156 }
157
158 pub async fn get_clips_by_ids(&mut self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
171 let mut clips = Vec::new();
172 let mut seen: BTreeSet<&str> = BTreeSet::new();
173 for id in ids {
174 if id.is_empty() || !seen.insert(id) {
175 continue;
176 }
177 let path = format!("/api/clip/{id}");
178 match self.api_get_retrying(http, &path).await {
179 Ok(body) => {
180 if let Some(clip) = parse_clip(&body) {
181 clips.push(clip);
182 }
183 }
184 Err(Error::NotFound(_)) => continue,
185 Err(err) => return Err(err),
186 }
187 }
188 Ok(clips)
189 }
190
191 pub async fn get_clip_parent(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
199 let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
200 match self.api_get_retrying(http, &path).await {
201 Ok(body) => Ok(parse_clip(&body)),
202 Err(Error::NotFound(_)) => Ok(None),
203 Err(err) => Err(err),
204 }
205 }
206
207 pub async fn get_playlists(&mut self, http: &impl Http) -> Result<Vec<Playlist>> {
218 let mut playlists = Vec::new();
219 for page in 1..=MAX_PAGES {
220 let path =
221 format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
222 let body = self.api_get_retrying(http, &path).await?;
223 let page_playlists = parse_playlists(&body)?;
224 if page_playlists.is_empty() {
225 break;
226 }
227 playlists.extend(page_playlists);
228 }
229 Ok(playlists)
230 }
231
232 pub async fn get_playlist_clips(
248 &mut self,
249 http: &impl Http,
250 id: &str,
251 ) -> Result<(Vec<Clip>, bool)> {
252 let path = format!("{PLAYLIST_PATH}{id}/");
253 let body = self.api_get_retrying(http, &path).await?;
254 parse_playlist_clips(&body)
255 }
256
257 async fn try_get_clip(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
260 let path = format!("/api/clip/{id}");
261 match self.api_get_retrying(http, &path).await {
262 Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
263 Err(Error::NotFound(_)) => Ok(None),
264 Err(err) => Err(err),
265 }
266 }
267
268 async fn find_in_feed(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
270 let (clips, _complete) = self.list_clips(http, false, None).await?;
271 clips
272 .into_iter()
273 .find(|clip| clip.id == id)
274 .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
275 }
276
277 async fn api_get(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
279 self.api_request(http, Method::Get, path, Vec::new()).await
280 }
281
282 async fn api_get_retrying(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
284 self.api_send_retrying(http, Method::Get, path, Vec::new())
285 .await
286 }
287
288 async fn api_send_retrying(
307 &mut self,
308 http: &impl Http,
309 method: Method,
310 path: &str,
311 body: Vec<u8>,
312 ) -> Result<Vec<u8>> {
313 let pace = self.limiter.pace();
314 if !pace.is_zero() {
315 self.clock.sleep(pace).await;
316 }
317 let mut retries = 0;
318 loop {
319 match self.api_request(http, method, path, body.clone()).await {
320 Ok(response) => return Ok(response),
321 Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
322 self.clock.sleep(retry_after_delay(retry_after)).await;
323 retries += 1;
324 }
325 Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
326 self.clock.sleep(backoff_delay(retries, None)).await;
327 retries += 1;
328 }
329 Err(err) => return Err(err),
330 }
331 }
332 }
333
334 async fn api_request(
339 &mut self,
340 http: &impl Http,
341 method: Method,
342 path: &str,
343 body: Vec<u8>,
344 ) -> Result<Vec<u8>> {
345 let url = format!("{SUNO_API_BASE_URL}{path}");
346 let mut auth_refreshed = false;
347 loop {
348 let jwt = self.auth.ensure_jwt(http).await?;
349 let mut request = match method {
350 Method::Get => HttpRequest::get(url.clone()),
351 Method::Post => HttpRequest::post(url.clone(), body.clone()),
352 };
353 request
354 .headers
355 .push(("Authorization".to_string(), format!("Bearer {jwt}")));
356 let response = http
357 .send(request)
358 .await
359 .map_err(|err| Error::Connection(err.to_string()))?;
360 match response.status {
361 200..=299 => {
362 self.limiter.on_success();
363 return Ok(response.body);
364 }
365 401 | 403 if !auth_refreshed => {
366 self.auth.invalidate_jwt();
367 auth_refreshed = true;
368 }
369 401 | 403 => {
370 return Err(Error::Auth(format!(
371 "Suno API auth failed with status {}",
372 response.status
373 )));
374 }
375 429 => {
376 self.limiter.on_rate_limit();
377 return Err(Error::RateLimited {
378 retry_after: retry_after(&response),
379 });
380 }
381 404 => {
382 return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
383 }
384 status => {
385 let preview: String = String::from_utf8_lossy(&response.body)
386 .chars()
387 .take(200)
388 .collect();
389 return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
390 }
391 }
392 }
393 }
394}
395
396fn parse_clip(body: &[u8]) -> Option<Clip> {
399 let data: Value = serde_json::from_slice(body).ok()?;
400 let raw = data
401 .get("clip")
402 .filter(|value| value.is_object())
403 .unwrap_or(&data);
404 let has_id = raw
405 .get("id")
406 .and_then(Value::as_str)
407 .is_some_and(|id| !id.is_empty());
408 has_id.then(|| Clip::from_json(raw))
409}
410
411fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
418 let mut filters = serde_json::Map::new();
419 filters.insert("trashed".to_string(), Value::String("False".to_string()));
420 if liked {
421 filters.insert("liked".to_string(), Value::String("True".to_string()));
422 }
423 let mut body = serde_json::Map::new();
424 body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
425 body.insert("filters".to_string(), Value::Object(filters));
426 if let Some(cursor) = cursor {
427 body.insert("cursor".to_string(), Value::String(cursor.to_string()));
428 }
429 serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
430}
431
432fn parse_feed_v3(body: &[u8]) -> Result<(Vec<Clip>, Option<bool>, Option<String>)> {
439 let data: Value = serde_json::from_slice(body)
440 .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
441 let Some(object) = data.as_object() else {
442 return Ok((Vec::new(), None, None));
443 };
444 let clips = object
445 .get("clips")
446 .and_then(Value::as_array)
447 .map(|raw| {
448 raw.iter()
449 .map(Clip::from_json)
450 .filter(is_downloadable)
451 .collect()
452 })
453 .unwrap_or_default();
454 let has_more = object.get("has_more").and_then(Value::as_bool);
455 let next_cursor = object
456 .get("next_cursor")
457 .and_then(Value::as_str)
458 .filter(|cursor| !cursor.is_empty())
459 .map(str::to_string);
460 Ok((clips, has_more, next_cursor))
461}
462
463fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
465 let data: Value = serde_json::from_slice(body)
466 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
467 Ok(data
468 .get("playlists")
469 .and_then(Value::as_array)
470 .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
471 .unwrap_or_default())
472}
473
474fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
479 let id = raw
480 .get("id")
481 .and_then(Value::as_str)
482 .filter(|id| !id.is_empty())?
483 .to_string();
484 let name = match raw.get("name") {
485 Some(Value::String(name)) if !name.is_empty() => name.clone(),
486 _ => "Untitled".to_string(),
487 };
488 let num_clips = raw
489 .get("num_total_results")
490 .and_then(Value::as_u64)
491 .unwrap_or(0);
492 Some(Playlist {
493 id,
494 name,
495 num_clips,
496 })
497}
498
499fn parse_playlist_clips(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
516 let data: Value = serde_json::from_slice(body)
517 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
518 let raw = data.get("playlist_clips").and_then(Value::as_array);
519 let raw_len = raw.map(|a| a.len()).unwrap_or(0);
520 let clips: Vec<Clip> = raw
521 .map(|raw| {
522 raw.iter()
523 .map(|entry| {
524 let clip = entry
525 .get("clip")
526 .filter(|value| value.is_object())
527 .unwrap_or(entry);
528 Clip::from_json(clip)
529 })
530 .filter(|clip| !clip.id.is_empty())
531 .collect()
532 })
533 .unwrap_or_default();
534 let complete = data
540 .get("num_total_results")
541 .and_then(Value::as_u64)
542 .is_some_and(|total| raw_len as u64 == total);
543 Ok((clips, complete))
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
550 use std::time::Duration;
551
552 fn feed_body() -> String {
553 serde_json::json!({
554 "has_more": false,
555 "clips": [
556 {
557 "id": "a", "title": "Song A", "status": "complete",
558 "audio_url": "https://cdn1.suno.ai/a.mp3",
559 "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
560 },
561 {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
562 {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
563 {
564 "id": "d", "title": "Context", "status": "complete",
565 "metadata": {"type": "rendered_context_window"}
566 }
567 ]
568 })
569 .to_string()
570 }
571
572 #[test]
573 fn parse_feed_v3_filters_and_reads_pagination() {
574 let (clips, has_more, next_cursor) = parse_feed_v3(feed_body().as_bytes()).unwrap();
575 assert_eq!(has_more, Some(false));
576 assert_eq!(next_cursor, None);
577 assert_eq!(clips.len(), 1);
578 assert_eq!(clips[0].id, "a");
579 assert_eq!(clips[0].tags, "rock");
580 assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
581 }
582
583 #[test]
584 fn feed_v3_body_carries_filters_and_optional_cursor() {
585 let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
586 assert_eq!(first["filters"]["trashed"], "False");
587 assert!(first.get("cursor").is_none());
588 assert!(first["filters"].get("liked").is_none());
589
590 let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
591 assert_eq!(liked["filters"]["liked"], "True");
592 assert_eq!(liked["cursor"], "cur42");
593 }
594
595 #[test]
596 fn audiopipe_url_is_rewritten_to_cdn() {
597 let raw =
598 serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
599 assert_eq!(
600 Clip::from_json(&raw).audio_url,
601 "https://cdn1.suno.ai/x.mp3"
602 );
603 }
604
605 #[test]
606 fn list_clips_authenticates_then_reads_the_feed() {
607 let client_body = serde_json::json!({
608 "response": {
609 "last_active_session_id": "s",
610 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
611 }
612 })
613 .to_string();
614 let http = MockHttp::new(vec![
615 Rule::new(
616 "/v1/client/sessions/",
617 200,
618 r#"{"jwt": "a.b.c"}"#.to_string(),
619 ),
620 Rule::new("/v1/client", 200, client_body),
621 Rule::new("/api/feed/v3", 200, feed_body()),
622 ]);
623
624 let mut auth = ClerkAuth::new("eyJtoken");
625 pollster::block_on(auth.authenticate(&http)).unwrap();
626 let mut client = SunoClient::new(auth, RecordingClock::new());
627 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
628 assert_eq!(clips.len(), 1);
629 assert_eq!(clips[0].id, "a");
630 assert!(complete);
631 }
632
633 #[test]
634 fn list_clips_reports_incomplete_when_paging_is_capped() {
635 let mut rules = auth_rules();
636 rules.push(Rule::new(
637 "/api/feed/v3",
638 200,
639 serde_json::json!({
640 "has_more": true,
641 "next_cursor": "cur1",
642 "clips": [{
643 "id": "a", "title": "Song A", "status": "complete",
644 "audio_url": "https://cdn1.suno.ai/a.mp3",
645 "metadata": {"type": "gen"}
646 }]
647 })
648 .to_string(),
649 ));
650 let http = MockHttp::new(rules);
651 let mut client = authed_client(&http);
652
653 let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
654 assert!(!complete);
655 }
656
657 fn auth_rules() -> Vec<Rule> {
658 let client_body = serde_json::json!({
659 "response": {
660 "last_active_session_id": "s",
661 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
662 }
663 })
664 .to_string();
665 vec![
666 Rule::new(
667 "/v1/client/sessions/",
668 200,
669 r#"{"jwt": "a.b.c"}"#.to_string(),
670 ),
671 Rule::new("/v1/client", 200, client_body),
672 ]
673 }
674
675 fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
676 let mut auth = ClerkAuth::new("eyJtoken");
677 pollster::block_on(auth.authenticate(http)).unwrap();
678 SunoClient::new(auth, RecordingClock::new())
679 }
680
681 fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
682 let mut auth = ClerkAuth::new("eyJtoken");
683 pollster::block_on(auth.authenticate(http)).unwrap();
684 SunoClient::new(auth, clock)
685 }
686
687 fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
688 let mut page = serde_json::json!({
689 "has_more": next_cursor.is_some(),
690 "clips": [{
691 "id": id, "title": "Song", "status": "complete",
692 "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
693 "metadata": {"type": "gen"}
694 }]
695 });
696 if let Some(cursor) = next_cursor {
697 page["next_cursor"] = serde_json::json!(cursor);
698 }
699 page.to_string()
700 }
701
702 #[test]
703 fn list_clips_retries_a_rate_limited_page() {
704 let http = ScriptedHttp::new().with_auth().route_seq(
705 "/api/feed/v3",
706 vec![Reply::status(429), Reply::json(&feed_body())],
707 );
708 let clock = RecordingClock::new();
709 let mut client = scripted_client(&http, clock.clone());
710
711 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
712 assert_eq!(clips.len(), 1);
713 assert!(complete);
714 assert_eq!(http.count("/api/feed/v3"), 2);
716 assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
717 }
718
719 #[test]
720 fn list_clips_honours_retry_after_on_a_throttled_page() {
721 let http = ScriptedHttp::new().with_auth().route_seq(
722 "/api/feed/v3",
723 vec![
724 Reply::status(429).with_retry_after(7),
725 Reply::json(&feed_body()),
726 ],
727 );
728 let clock = RecordingClock::new();
729 let mut client = scripted_client(&http, clock.clone());
730
731 let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
732 assert_eq!(clips.len(), 1);
733 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
735 }
736
737 #[test]
738 fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
739 let http = ScriptedHttp::new().with_auth().route_seq(
741 "/api/feed/v3",
742 vec![
743 Reply::json(&one_clip_page("a", Some("cur1"))),
744 Reply::status(429),
745 Reply::json(&one_clip_page("b", None)),
746 ],
747 );
748 let clock = RecordingClock::new();
749 let mut client = scripted_client(&http, clock.clone());
750
751 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
752 assert!(complete);
753 assert_eq!(clips.len(), 2);
754 let bodies = http.bodies();
755 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
756 assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
757 let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
760 let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
761 assert_eq!(retried["cursor"], "cur1");
762 assert_eq!(after_retry["cursor"], "cur1");
763 }
764
765 #[test]
766 fn list_clips_threads_the_cursor_across_pages() {
767 let http = ScriptedHttp::new().with_auth().route_seq(
768 "/api/feed/v3",
769 vec![
770 Reply::json(&one_clip_page("a", Some("cur1"))),
771 Reply::json(&one_clip_page("b", None)),
772 ],
773 );
774 let clock = RecordingClock::new();
775 let mut client = scripted_client(&http, clock.clone());
776
777 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
778 assert!(complete);
779 assert_eq!(clips.len(), 2);
780 let bodies = http.bodies();
781 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
782 assert_eq!(feed_bodies.len(), 2);
783 let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
784 let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
785 assert!(page1.get("cursor").is_none());
787 assert_eq!(page2["cursor"], "cur1");
788 }
789
790 #[test]
791 fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
792 let page = serde_json::json!({
795 "has_more": true,
796 "clips": [{
797 "id": "a", "title": "Song", "status": "complete",
798 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
799 }]
800 })
801 .to_string();
802 let http = ScriptedHttp::new()
803 .with_auth()
804 .route("/api/feed/v3", Reply::json(&page));
805 let clock = RecordingClock::new();
806 let mut client = scripted_client(&http, clock.clone());
807
808 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
809 assert!(!complete);
810 assert_eq!(clips.len(), 1);
811 assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
812 }
813
814 #[test]
815 fn list_clips_is_incomplete_when_has_more_is_missing() {
816 let page = serde_json::json!({
818 "clips": [{
819 "id": "a", "title": "Song", "status": "complete",
820 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
821 }]
822 })
823 .to_string();
824 let http = ScriptedHttp::new()
825 .with_auth()
826 .route("/api/feed/v3", Reply::json(&page));
827 let clock = RecordingClock::new();
828 let mut client = scripted_client(&http, clock.clone());
829
830 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
831 assert!(!complete);
832 assert_eq!(clips.len(), 1);
833 assert_eq!(http.count("/api/feed/v3"), 1);
834 }
835
836 #[test]
837 fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
838 let http = ScriptedHttp::new().with_auth().route_seq(
839 "/api/feed/v3",
840 vec![
841 Reply::json(&one_clip_page("a", Some("cur1"))),
842 Reply::status(500),
843 ],
844 );
845 let clock = RecordingClock::new();
846 let mut client = scripted_client(&http, clock.clone());
847
848 let result = pollster::block_on(client.list_clips(&http, false, None));
849 assert!(matches!(result, Err(Error::Api(_))));
850 }
851
852 #[test]
853 fn list_clips_is_complete_on_an_empty_drained_feed() {
854 let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
857 let http = ScriptedHttp::new()
858 .with_auth()
859 .route("/api/feed/v3", Reply::json(&page));
860 let clock = RecordingClock::new();
861 let mut client = scripted_client(&http, clock.clone());
862
863 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
864 assert!(complete);
865 assert!(clips.is_empty());
866 }
867
868 #[test]
869 fn list_clips_liked_scope_sends_the_liked_filter() {
870 let http = ScriptedHttp::new()
871 .with_auth()
872 .route("/api/feed/v3", Reply::json(&feed_body()));
873 let clock = RecordingClock::new();
874 let mut client = scripted_client(&http, clock.clone());
875
876 let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
877 let bodies = http.bodies();
878 let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
879 let value: Value = serde_json::from_str(feed_body).unwrap();
880 assert_eq!(value["filters"]["liked"], "True");
881 assert_eq!(value["filters"]["trashed"], "False");
882 }
883
884 #[test]
885 fn list_clips_does_not_pace_an_unthrottled_walk() {
886 let http = ScriptedHttp::new().with_auth().route_seq(
887 "/api/feed/v3",
888 vec![
889 Reply::json(&one_clip_page("a", Some("cur1"))),
890 Reply::json(&one_clip_page("e", None)),
891 ],
892 );
893 let clock = RecordingClock::new();
894 let mut client = scripted_client(&http, clock.clone());
895
896 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
897 assert!(complete);
898 assert_eq!(clips.len(), 2);
899 assert_eq!(http.count("/api/feed/v3"), 2);
900 assert!(clock.sleeps().is_empty());
902 }
903
904 #[test]
905 fn list_clips_slows_its_pace_after_a_throttled_page() {
906 let http = ScriptedHttp::new().with_auth().route_seq(
907 "/api/feed/v3",
908 vec![
909 Reply::status(429),
910 Reply::json(&one_clip_page("a", Some("cur1"))),
911 Reply::json(&one_clip_page("e", None)),
912 ],
913 );
914 let clock = RecordingClock::new();
915 let mut client = scripted_client(&http, clock.clone());
916
917 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
918 assert!(complete);
919 assert_eq!(clips.len(), 2);
920 assert_eq!(
923 clock.sleeps(),
924 vec![Duration::from_secs(5), Duration::from_secs(1)]
925 );
926 }
927
928 #[test]
929 fn list_clips_gives_up_after_max_retries() {
930 let http = ScriptedHttp::new()
931 .with_auth()
932 .route("/api/feed/v3", Reply::status(429));
933 let clock = RecordingClock::new();
934 let mut client = scripted_client(&http, clock.clone());
935
936 let result = pollster::block_on(client.list_clips(&http, false, None));
937 assert!(matches!(result, Err(Error::RateLimited { .. })));
938 let budget = crate::consts::API_MAX_RETRIES as usize;
939 assert_eq!(clock.sleeps().len(), budget);
940 assert_eq!(http.count("/api/feed/v3"), budget + 1);
941 }
942
943 #[test]
944 fn parse_clip_accepts_bare_and_wrapped_shapes() {
945 let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
946 assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
947
948 let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
949 assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
950
951 let missing = serde_json::json!({"detail": "not found"}).to_string();
952 assert!(parse_clip(missing.as_bytes()).is_none());
953 }
954
955 #[test]
956 fn get_clip_uses_the_dedicated_endpoint() {
957 let clip_body = serde_json::json!({
958 "id": "z", "title": "Zed", "status": "complete",
959 "audio_url": "https://cdn1.suno.ai/z.mp3",
960 "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
961 })
962 .to_string();
963 let mut rules = auth_rules();
964 rules.push(Rule::new("/api/clip/", 200, clip_body));
965 let http = MockHttp::new(rules);
966 let mut client = authed_client(&http);
967
968 let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
969 assert_eq!(clip.id, "z");
970 assert_eq!(clip.title, "Zed");
971 assert_eq!(clip.tags, "jazz");
972 }
973
974 #[test]
975 fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
976 let mut rules = auth_rules();
977 rules.push(Rule::new(
978 "/api/clip/",
979 404,
980 r#"{"detail": "not found"}"#.to_string(),
981 ));
982 rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
983 let http = MockHttp::new(rules);
984 let mut client = authed_client(&http);
985
986 let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
987 assert_eq!(clip.id, "a");
988 assert_eq!(clip.tags, "rock");
989 }
990
991 #[test]
992 fn request_wav_accepts_a_2xx_status() {
993 let mut rules = auth_rules();
994 rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
995 let http = MockHttp::new(rules);
996 let mut client = authed_client(&http);
997
998 assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
999 }
1000
1001 #[test]
1002 fn wav_url_reads_the_ready_url() {
1003 let mut rules = auth_rules();
1004 rules.push(Rule::new(
1005 "/wav_file/",
1006 200,
1007 r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
1008 ));
1009 let http = MockHttp::new(rules);
1010 let mut client = authed_client(&http);
1011
1012 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1013 assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
1014 }
1015
1016 #[test]
1017 fn wav_url_is_none_until_the_render_is_ready() {
1018 let mut rules = auth_rules();
1019 rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
1020 let http = MockHttp::new(rules);
1021 let mut client = authed_client(&http);
1022
1023 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1024 assert_eq!(url, None);
1025 }
1026
1027 #[test]
1028 fn get_clips_by_ids_fetches_each_id_and_keeps_artefacts() {
1029 let p1 = serde_json::json!({
1033 "id": "p1", "title": "Infill Ancestor", "status": "complete",
1034 "metadata": {"type": "gen", "task": "infill"}
1035 })
1036 .to_string();
1037 let p2 = serde_json::json!({
1038 "id": "p2", "title": "Uploaded Root", "status": "complete",
1039 "metadata": {"type": "upload"}
1040 })
1041 .to_string();
1042 let mut rules = auth_rules();
1043 rules.push(Rule::new("/api/clip/p1", 200, p1));
1044 rules.push(Rule::new("/api/clip/p2", 200, p2));
1045 let http = MockHttp::new(rules);
1046 let mut client = authed_client(&http);
1047
1048 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"])).unwrap();
1049 assert_eq!(
1050 clips.len(),
1051 2,
1052 "infill and upload ancestors must not be filtered"
1053 );
1054 assert_eq!(clips[0].id, "p1");
1055 assert_eq!(clips[1].id, "p2");
1056 }
1057
1058 #[test]
1059 fn get_clips_by_ids_returns_a_trashed_clip() {
1060 let trashed = serde_json::json!({
1063 "id": "t1", "title": "Trashed Ancestor", "status": "complete",
1064 "is_trashed": true, "metadata": {"type": "gen"}
1065 })
1066 .to_string();
1067 let mut rules = auth_rules();
1068 rules.push(Rule::new("/api/clip/t1", 200, trashed));
1069 let http = MockHttp::new(rules);
1070 let mut client = authed_client(&http);
1071
1072 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"])).unwrap();
1073 assert_eq!(clips.len(), 1);
1074 assert_eq!(clips[0].id, "t1");
1075 assert!(clips[0].is_trashed);
1076 }
1077
1078 #[test]
1079 fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
1080 let only = serde_json::json!({
1081 "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
1082 })
1083 .to_string();
1084 let http = ScriptedHttp::new()
1085 .with_auth()
1086 .route("/api/clip/gone", Reply::status(404))
1087 .route("/api/clip/only", Reply::json(&only));
1088 let mut client = scripted_client(&http, RecordingClock::new());
1089
1090 let clips =
1091 pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"])).unwrap();
1092 assert_eq!(clips.len(), 1, "the 404 id is skipped");
1093 assert_eq!(clips[0].id, "only");
1094 assert_eq!(http.count("/api/clip/only"), 1);
1096 assert_eq!(http.count("/api/clip/gone"), 1);
1097 }
1098
1099 #[test]
1100 fn get_clip_parent_reads_the_parent_clip() {
1101 let parent = serde_json::json!({
1102 "id": "par", "title": "Ancestor", "status": "complete",
1103 "metadata": {"type": "gen"}
1104 })
1105 .to_string();
1106 let mut rules = auth_rules();
1107 rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
1108 let http = MockHttp::new(rules);
1109 let mut client = authed_client(&http);
1110
1111 let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
1112 assert_eq!(clip.unwrap().id, "par");
1113 }
1114
1115 #[test]
1116 fn get_clip_parent_is_none_for_a_root() {
1117 let mut rules = auth_rules();
1118 rules.push(Rule::new(
1119 "/api/clips/parent",
1120 404,
1121 r#"{"detail": "no parent"}"#.to_string(),
1122 ));
1123 let http = MockHttp::new(rules);
1124 let mut client = authed_client(&http);
1125
1126 let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
1127 assert!(clip.is_none());
1128 }
1129
1130 #[test]
1131 fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
1132 for status in [500u16, 503] {
1136 let mut rules = auth_rules();
1137 rules.push(Rule::new(
1138 "/api/clips/parent",
1139 status,
1140 r#"{"detail": "server error"}"#.to_string(),
1141 ));
1142 let http = MockHttp::new(rules);
1143 let mut client = authed_client(&http);
1144
1145 let result = pollster::block_on(client.get_clip_parent(&http, "child"));
1146 assert!(
1147 matches!(result, Err(Error::Api(_))),
1148 "status {status} must propagate as an error, not Ok(None)"
1149 );
1150 }
1151 }
1152
1153 #[test]
1154 fn get_playlists_maps_entries_and_skips_missing_ids() {
1155 let page1 = serde_json::json!({
1156 "playlists": [
1157 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
1158 {"id": "", "name": "No Id", "num_total_results": 3},
1159 {"name": "Also No Id"}
1160 ]
1161 })
1162 .to_string();
1163 let mut rules = auth_rules();
1164 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1166 rules.push(Rule::new(
1167 "/api/playlist/me?page=2",
1168 200,
1169 r#"{"playlists": []}"#.to_string(),
1170 ));
1171 let http = MockHttp::new(rules);
1172 let mut client = authed_client(&http);
1173
1174 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1175 assert_eq!(playlists.len(), 1, "entries without an id are dropped");
1176 assert_eq!(
1177 playlists[0],
1178 Playlist {
1179 id: "pl1".to_owned(),
1180 name: "Road Trip".to_owned(),
1181 num_clips: 12,
1182 }
1183 );
1184 }
1185
1186 #[test]
1187 fn get_playlists_defaults_a_missing_name_to_untitled() {
1188 let page1 = serde_json::json!({
1189 "playlists": [{"id": "pl9", "num_total_results": 1}]
1190 })
1191 .to_string();
1192 let mut rules = auth_rules();
1193 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1194 rules.push(Rule::new(
1195 "/api/playlist/me?page=2",
1196 200,
1197 r#"{"playlists": []}"#.to_string(),
1198 ));
1199 let http = MockHttp::new(rules);
1200 let mut client = authed_client(&http);
1201
1202 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1203 assert_eq!(playlists[0].name, "Untitled");
1204 }
1205
1206 #[test]
1207 fn get_playlist_clips_preserves_order_and_unwraps_clip() {
1208 let body = serde_json::json!({
1211 "num_total_results": 2,
1212 "playlist_clips": [
1213 {"clip": {
1214 "id": "second", "title": "Second", "status": "complete",
1215 "metadata": {"duration": 60.0, "type": "gen"}
1216 }},
1217 {"clip": {
1218 "id": "first", "title": "First", "status": "complete",
1219 "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
1220 }}
1221 ]
1222 })
1223 .to_string();
1224 let mut rules = auth_rules();
1225 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1226 let http = MockHttp::new(rules);
1227 let mut client = authed_client(&http);
1228
1229 let (clips, complete) =
1230 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1231 assert_eq!(clips.len(), 2, "an infill member is not filtered out");
1232 assert_eq!(clips[0].id, "second");
1233 assert_eq!(clips[1].id, "first");
1234 assert!(
1235 complete,
1236 "returned == num_total_results is fully enumerated"
1237 );
1238 }
1239
1240 #[test]
1241 fn get_playlist_clips_short_page_is_not_complete() {
1242 let body = serde_json::json!({
1244 "num_total_results": 5,
1245 "playlist_clips": [
1246 {"clip": {
1247 "id": "only", "title": "Only", "status": "complete",
1248 "metadata": {"duration": 60.0, "type": "gen"}
1249 }}
1250 ]
1251 })
1252 .to_string();
1253 let mut rules = auth_rules();
1254 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1255 let http = MockHttp::new(rules);
1256 let mut client = authed_client(&http);
1257
1258 let (clips, complete) =
1259 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1260 assert_eq!(clips.len(), 1);
1261 assert!(!complete, "a short page is not fully enumerated");
1262 }
1263
1264 #[test]
1265 fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
1266 let mut rules = auth_rules();
1267 rules.push(Rule::new(
1268 "/api/playlist/empty/",
1269 200,
1270 r#"{"num_total_results": 0, "playlist_clips": []}"#.to_string(),
1271 ));
1272 let http = MockHttp::new(rules);
1273 let mut client = authed_client(&http);
1274
1275 let (clips, complete) =
1276 pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
1277 assert!(clips.is_empty());
1278 assert!(
1279 complete,
1280 "an empty playlist reporting zero total is complete"
1281 );
1282 }
1283
1284 #[test]
1285 fn get_playlist_clips_missing_total_is_not_complete() {
1286 let mut rules = auth_rules();
1290 rules.push(Rule::new(
1291 "/api/playlist/pl1/",
1292 200,
1293 r#"{"playlist_clips": []}"#.to_string(),
1294 ));
1295 let http = MockHttp::new(rules);
1296 let mut client = authed_client(&http);
1297
1298 let (clips, complete) =
1299 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1300 assert!(clips.is_empty());
1301 assert!(!complete, "a missing total is never fully enumerated");
1302 }
1303}