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 pub async fn list_clips(
81 &mut self,
82 http: &impl Http,
83 liked: bool,
84 limit: Option<usize>,
85 ) -> Result<(Vec<Clip>, bool)> {
86 let mut clips = Vec::new();
87 let mut cursor: Option<String> = None;
88 let mut complete = false;
89 for _ in 0..MAX_PAGES {
90 let body = feed_v3_body(liked, cursor.as_deref());
91 let response = self
92 .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
93 .await?;
94 let (page_clips, has_more, next_cursor) = parse_feed_v3(&response)?;
95 clips.extend(page_clips);
96 match has_more {
97 Some(false) => {
98 complete = true;
99 break;
100 }
101 Some(true) => match next_cursor {
102 Some(next) => cursor = Some(next),
103 None => break,
104 },
105 None => break,
106 }
107 if limit.is_some_and(|n| clips.len() >= n) {
108 break;
109 }
110 }
111 if let Some(n) = limit {
112 clips.truncate(n);
113 }
114 Ok((clips, complete))
115 }
116
117 pub async fn get_clip(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
123 if let Some(clip) = self.try_get_clip(http, id).await? {
124 return Ok(clip);
125 }
126 self.find_in_feed(http, id).await
127 }
128
129 pub async fn request_wav(&mut self, http: &impl Http, id: &str) -> Result<()> {
131 let path = format!("/api/gen/{id}/convert_wav/");
132 self.api_request(http, Method::Post, &path, Vec::new())
133 .await?;
134 Ok(())
135 }
136
137 pub async fn wav_url(&mut self, http: &impl Http, id: &str) -> Result<Option<String>> {
139 let path = format!("/api/gen/{id}/wav_file/");
140 let body = self.api_get(http, &path).await?;
141 let data: Value = serde_json::from_slice(&body)
142 .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
143 Ok(data
144 .get("wav_file_url")
145 .and_then(Value::as_str)
146 .filter(|url| !url.is_empty())
147 .map(str::to_string))
148 }
149
150 pub async fn get_clips_by_ids(&mut self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
163 let mut clips = Vec::new();
164 let mut seen: BTreeSet<&str> = BTreeSet::new();
165 for id in ids {
166 if id.is_empty() || !seen.insert(id) {
167 continue;
168 }
169 let path = format!("/api/clip/{id}");
170 match self.api_get_retrying(http, &path).await {
171 Ok(body) => {
172 if let Some(clip) = parse_clip(&body) {
173 clips.push(clip);
174 }
175 }
176 Err(Error::NotFound(_)) => continue,
177 Err(err) => return Err(err),
178 }
179 }
180 Ok(clips)
181 }
182
183 pub async fn get_clip_parent(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
191 let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
192 match self.api_get_retrying(http, &path).await {
193 Ok(body) => Ok(parse_clip(&body)),
194 Err(Error::NotFound(_)) => Ok(None),
195 Err(err) => Err(err),
196 }
197 }
198
199 pub async fn get_playlists(&mut self, http: &impl Http) -> Result<Vec<Playlist>> {
210 let mut playlists = Vec::new();
211 for page in 1..=MAX_PAGES {
212 let path =
213 format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
214 let body = self.api_get_retrying(http, &path).await?;
215 let page_playlists = parse_playlists(&body)?;
216 if page_playlists.is_empty() {
217 break;
218 }
219 playlists.extend(page_playlists);
220 }
221 Ok(playlists)
222 }
223
224 pub async fn get_playlist_clips(&mut self, http: &impl Http, id: &str) -> Result<Vec<Clip>> {
232 let path = format!("{PLAYLIST_PATH}{id}/");
233 let body = self.api_get_retrying(http, &path).await?;
234 parse_playlist_clips(&body)
235 }
236
237 async fn try_get_clip(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
240 let path = format!("/api/clip/{id}");
241 match self.api_get_retrying(http, &path).await {
242 Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
243 Err(Error::NotFound(_)) => Ok(None),
244 Err(err) => Err(err),
245 }
246 }
247
248 async fn find_in_feed(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
250 let (clips, _complete) = self.list_clips(http, false, None).await?;
251 clips
252 .into_iter()
253 .find(|clip| clip.id == id)
254 .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
255 }
256
257 async fn api_get(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
259 self.api_request(http, Method::Get, path, Vec::new()).await
260 }
261
262 async fn api_get_retrying(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
264 self.api_send_retrying(http, Method::Get, path, Vec::new())
265 .await
266 }
267
268 async fn api_send_retrying(
287 &mut self,
288 http: &impl Http,
289 method: Method,
290 path: &str,
291 body: Vec<u8>,
292 ) -> Result<Vec<u8>> {
293 let pace = self.limiter.pace();
294 if !pace.is_zero() {
295 self.clock.sleep(pace).await;
296 }
297 let mut retries = 0;
298 loop {
299 match self.api_request(http, method, path, body.clone()).await {
300 Ok(response) => return Ok(response),
301 Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
302 self.clock.sleep(retry_after_delay(retry_after)).await;
303 retries += 1;
304 }
305 Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
306 self.clock.sleep(backoff_delay(retries, None)).await;
307 retries += 1;
308 }
309 Err(err) => return Err(err),
310 }
311 }
312 }
313
314 async fn api_request(
319 &mut self,
320 http: &impl Http,
321 method: Method,
322 path: &str,
323 body: Vec<u8>,
324 ) -> Result<Vec<u8>> {
325 let url = format!("{SUNO_API_BASE_URL}{path}");
326 let mut auth_refreshed = false;
327 loop {
328 let jwt = self.auth.ensure_jwt(http).await?;
329 let mut request = match method {
330 Method::Get => HttpRequest::get(url.clone()),
331 Method::Post => HttpRequest::post(url.clone(), body.clone()),
332 };
333 request
334 .headers
335 .push(("Authorization".to_string(), format!("Bearer {jwt}")));
336 let response = http
337 .send(request)
338 .await
339 .map_err(|err| Error::Connection(err.to_string()))?;
340 match response.status {
341 200..=299 => {
342 self.limiter.on_success();
343 return Ok(response.body);
344 }
345 401 | 403 if !auth_refreshed => {
346 self.auth.invalidate_jwt();
347 auth_refreshed = true;
348 }
349 401 | 403 => {
350 return Err(Error::Auth(format!(
351 "Suno API auth failed with status {}",
352 response.status
353 )));
354 }
355 429 => {
356 self.limiter.on_rate_limit();
357 return Err(Error::RateLimited {
358 retry_after: retry_after(&response),
359 });
360 }
361 404 => {
362 return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
363 }
364 status => {
365 let preview: String = String::from_utf8_lossy(&response.body)
366 .chars()
367 .take(200)
368 .collect();
369 return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
370 }
371 }
372 }
373 }
374}
375
376fn parse_clip(body: &[u8]) -> Option<Clip> {
379 let data: Value = serde_json::from_slice(body).ok()?;
380 let raw = data
381 .get("clip")
382 .filter(|value| value.is_object())
383 .unwrap_or(&data);
384 let has_id = raw
385 .get("id")
386 .and_then(Value::as_str)
387 .is_some_and(|id| !id.is_empty());
388 has_id.then(|| Clip::from_json(raw))
389}
390
391fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
398 let mut filters = serde_json::Map::new();
399 filters.insert("trashed".to_string(), Value::String("False".to_string()));
400 if liked {
401 filters.insert("liked".to_string(), Value::String("True".to_string()));
402 }
403 let mut body = serde_json::Map::new();
404 body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
405 body.insert("filters".to_string(), Value::Object(filters));
406 if let Some(cursor) = cursor {
407 body.insert("cursor".to_string(), Value::String(cursor.to_string()));
408 }
409 serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
410}
411
412fn parse_feed_v3(body: &[u8]) -> Result<(Vec<Clip>, Option<bool>, Option<String>)> {
419 let data: Value = serde_json::from_slice(body)
420 .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
421 let Some(object) = data.as_object() else {
422 return Ok((Vec::new(), None, None));
423 };
424 let clips = object
425 .get("clips")
426 .and_then(Value::as_array)
427 .map(|raw| {
428 raw.iter()
429 .map(Clip::from_json)
430 .filter(is_downloadable)
431 .collect()
432 })
433 .unwrap_or_default();
434 let has_more = object.get("has_more").and_then(Value::as_bool);
435 let next_cursor = object
436 .get("next_cursor")
437 .and_then(Value::as_str)
438 .filter(|cursor| !cursor.is_empty())
439 .map(str::to_string);
440 Ok((clips, has_more, next_cursor))
441}
442
443fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
445 let data: Value = serde_json::from_slice(body)
446 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
447 Ok(data
448 .get("playlists")
449 .and_then(Value::as_array)
450 .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
451 .unwrap_or_default())
452}
453
454fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
459 let id = raw
460 .get("id")
461 .and_then(Value::as_str)
462 .filter(|id| !id.is_empty())?
463 .to_string();
464 let name = match raw.get("name") {
465 Some(Value::String(name)) if !name.is_empty() => name.clone(),
466 _ => "Untitled".to_string(),
467 };
468 let num_clips = raw
469 .get("num_total_results")
470 .and_then(Value::as_u64)
471 .unwrap_or(0);
472 Some(Playlist {
473 id,
474 name,
475 num_clips,
476 })
477}
478
479fn parse_playlist_clips(body: &[u8]) -> Result<Vec<Clip>> {
489 let data: Value = serde_json::from_slice(body)
490 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
491 Ok(data
492 .get("playlist_clips")
493 .and_then(Value::as_array)
494 .map(|raw| {
495 raw.iter()
496 .map(|entry| {
497 let clip = entry
498 .get("clip")
499 .filter(|value| value.is_object())
500 .unwrap_or(entry);
501 Clip::from_json(clip)
502 })
503 .filter(|clip| !clip.id.is_empty())
504 .collect()
505 })
506 .unwrap_or_default())
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
513 use std::time::Duration;
514
515 fn feed_body() -> String {
516 serde_json::json!({
517 "has_more": false,
518 "clips": [
519 {
520 "id": "a", "title": "Song A", "status": "complete",
521 "audio_url": "https://cdn1.suno.ai/a.mp3",
522 "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
523 },
524 {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
525 {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
526 {
527 "id": "d", "title": "Context", "status": "complete",
528 "metadata": {"type": "rendered_context_window"}
529 }
530 ]
531 })
532 .to_string()
533 }
534
535 #[test]
536 fn parse_feed_v3_filters_and_reads_pagination() {
537 let (clips, has_more, next_cursor) = parse_feed_v3(feed_body().as_bytes()).unwrap();
538 assert_eq!(has_more, Some(false));
539 assert_eq!(next_cursor, None);
540 assert_eq!(clips.len(), 1);
541 assert_eq!(clips[0].id, "a");
542 assert_eq!(clips[0].tags, "rock");
543 assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
544 }
545
546 #[test]
547 fn feed_v3_body_carries_filters_and_optional_cursor() {
548 let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
549 assert_eq!(first["filters"]["trashed"], "False");
550 assert!(first.get("cursor").is_none());
551 assert!(first["filters"].get("liked").is_none());
552
553 let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
554 assert_eq!(liked["filters"]["liked"], "True");
555 assert_eq!(liked["cursor"], "cur42");
556 }
557
558 #[test]
559 fn audiopipe_url_is_rewritten_to_cdn() {
560 let raw =
561 serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
562 assert_eq!(
563 Clip::from_json(&raw).audio_url,
564 "https://cdn1.suno.ai/x.mp3"
565 );
566 }
567
568 #[test]
569 fn list_clips_authenticates_then_reads_the_feed() {
570 let client_body = serde_json::json!({
571 "response": {
572 "last_active_session_id": "s",
573 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
574 }
575 })
576 .to_string();
577 let http = MockHttp::new(vec![
578 Rule::new(
579 "/v1/client/sessions/",
580 200,
581 r#"{"jwt": "a.b.c"}"#.to_string(),
582 ),
583 Rule::new("/v1/client", 200, client_body),
584 Rule::new("/api/feed/v3", 200, feed_body()),
585 ]);
586
587 let mut auth = ClerkAuth::new("eyJtoken");
588 pollster::block_on(auth.authenticate(&http)).unwrap();
589 let mut client = SunoClient::new(auth, RecordingClock::new());
590 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
591 assert_eq!(clips.len(), 1);
592 assert_eq!(clips[0].id, "a");
593 assert!(complete);
594 }
595
596 #[test]
597 fn list_clips_reports_incomplete_when_paging_is_capped() {
598 let mut rules = auth_rules();
599 rules.push(Rule::new(
600 "/api/feed/v3",
601 200,
602 serde_json::json!({
603 "has_more": true,
604 "next_cursor": "cur1",
605 "clips": [{
606 "id": "a", "title": "Song A", "status": "complete",
607 "audio_url": "https://cdn1.suno.ai/a.mp3",
608 "metadata": {"type": "gen"}
609 }]
610 })
611 .to_string(),
612 ));
613 let http = MockHttp::new(rules);
614 let mut client = authed_client(&http);
615
616 let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
617 assert!(!complete);
618 }
619
620 fn auth_rules() -> Vec<Rule> {
621 let client_body = serde_json::json!({
622 "response": {
623 "last_active_session_id": "s",
624 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
625 }
626 })
627 .to_string();
628 vec![
629 Rule::new(
630 "/v1/client/sessions/",
631 200,
632 r#"{"jwt": "a.b.c"}"#.to_string(),
633 ),
634 Rule::new("/v1/client", 200, client_body),
635 ]
636 }
637
638 fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
639 let mut auth = ClerkAuth::new("eyJtoken");
640 pollster::block_on(auth.authenticate(http)).unwrap();
641 SunoClient::new(auth, RecordingClock::new())
642 }
643
644 fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
645 let mut auth = ClerkAuth::new("eyJtoken");
646 pollster::block_on(auth.authenticate(http)).unwrap();
647 SunoClient::new(auth, clock)
648 }
649
650 fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
651 let mut page = serde_json::json!({
652 "has_more": next_cursor.is_some(),
653 "clips": [{
654 "id": id, "title": "Song", "status": "complete",
655 "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
656 "metadata": {"type": "gen"}
657 }]
658 });
659 if let Some(cursor) = next_cursor {
660 page["next_cursor"] = serde_json::json!(cursor);
661 }
662 page.to_string()
663 }
664
665 #[test]
666 fn list_clips_retries_a_rate_limited_page() {
667 let http = ScriptedHttp::new().with_auth().route_seq(
668 "/api/feed/v3",
669 vec![Reply::status(429), Reply::json(&feed_body())],
670 );
671 let clock = RecordingClock::new();
672 let mut client = scripted_client(&http, clock.clone());
673
674 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
675 assert_eq!(clips.len(), 1);
676 assert!(complete);
677 assert_eq!(http.count("/api/feed/v3"), 2);
679 assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
680 }
681
682 #[test]
683 fn list_clips_honours_retry_after_on_a_throttled_page() {
684 let http = ScriptedHttp::new().with_auth().route_seq(
685 "/api/feed/v3",
686 vec![
687 Reply::status(429).with_retry_after(7),
688 Reply::json(&feed_body()),
689 ],
690 );
691 let clock = RecordingClock::new();
692 let mut client = scripted_client(&http, clock.clone());
693
694 let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
695 assert_eq!(clips.len(), 1);
696 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
698 }
699
700 #[test]
701 fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
702 let http = ScriptedHttp::new().with_auth().route_seq(
704 "/api/feed/v3",
705 vec![
706 Reply::json(&one_clip_page("a", Some("cur1"))),
707 Reply::status(429),
708 Reply::json(&one_clip_page("b", None)),
709 ],
710 );
711 let clock = RecordingClock::new();
712 let mut client = scripted_client(&http, clock.clone());
713
714 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
715 assert!(complete);
716 assert_eq!(clips.len(), 2);
717 let bodies = http.bodies();
718 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
719 assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
720 let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
723 let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
724 assert_eq!(retried["cursor"], "cur1");
725 assert_eq!(after_retry["cursor"], "cur1");
726 }
727
728 #[test]
729 fn list_clips_threads_the_cursor_across_pages() {
730 let http = ScriptedHttp::new().with_auth().route_seq(
731 "/api/feed/v3",
732 vec![
733 Reply::json(&one_clip_page("a", Some("cur1"))),
734 Reply::json(&one_clip_page("b", None)),
735 ],
736 );
737 let clock = RecordingClock::new();
738 let mut client = scripted_client(&http, clock.clone());
739
740 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
741 assert!(complete);
742 assert_eq!(clips.len(), 2);
743 let bodies = http.bodies();
744 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
745 assert_eq!(feed_bodies.len(), 2);
746 let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
747 let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
748 assert!(page1.get("cursor").is_none());
750 assert_eq!(page2["cursor"], "cur1");
751 }
752
753 #[test]
754 fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
755 let page = serde_json::json!({
758 "has_more": true,
759 "clips": [{
760 "id": "a", "title": "Song", "status": "complete",
761 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
762 }]
763 })
764 .to_string();
765 let http = ScriptedHttp::new()
766 .with_auth()
767 .route("/api/feed/v3", Reply::json(&page));
768 let clock = RecordingClock::new();
769 let mut client = scripted_client(&http, clock.clone());
770
771 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
772 assert!(!complete);
773 assert_eq!(clips.len(), 1);
774 assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
775 }
776
777 #[test]
778 fn list_clips_is_incomplete_when_has_more_is_missing() {
779 let page = serde_json::json!({
781 "clips": [{
782 "id": "a", "title": "Song", "status": "complete",
783 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
784 }]
785 })
786 .to_string();
787 let http = ScriptedHttp::new()
788 .with_auth()
789 .route("/api/feed/v3", Reply::json(&page));
790 let clock = RecordingClock::new();
791 let mut client = scripted_client(&http, clock.clone());
792
793 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
794 assert!(!complete);
795 assert_eq!(clips.len(), 1);
796 assert_eq!(http.count("/api/feed/v3"), 1);
797 }
798
799 #[test]
800 fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
801 let http = ScriptedHttp::new().with_auth().route_seq(
802 "/api/feed/v3",
803 vec![
804 Reply::json(&one_clip_page("a", Some("cur1"))),
805 Reply::status(500),
806 ],
807 );
808 let clock = RecordingClock::new();
809 let mut client = scripted_client(&http, clock.clone());
810
811 let result = pollster::block_on(client.list_clips(&http, false, None));
812 assert!(matches!(result, Err(Error::Api(_))));
813 }
814
815 #[test]
816 fn list_clips_is_complete_on_an_empty_drained_feed() {
817 let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
820 let http = ScriptedHttp::new()
821 .with_auth()
822 .route("/api/feed/v3", Reply::json(&page));
823 let clock = RecordingClock::new();
824 let mut client = scripted_client(&http, clock.clone());
825
826 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
827 assert!(complete);
828 assert!(clips.is_empty());
829 }
830
831 #[test]
832 fn list_clips_liked_scope_sends_the_liked_filter() {
833 let http = ScriptedHttp::new()
834 .with_auth()
835 .route("/api/feed/v3", Reply::json(&feed_body()));
836 let clock = RecordingClock::new();
837 let mut client = scripted_client(&http, clock.clone());
838
839 let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
840 let bodies = http.bodies();
841 let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
842 let value: Value = serde_json::from_str(feed_body).unwrap();
843 assert_eq!(value["filters"]["liked"], "True");
844 assert_eq!(value["filters"]["trashed"], "False");
845 }
846
847 #[test]
848 fn list_clips_does_not_pace_an_unthrottled_walk() {
849 let http = ScriptedHttp::new().with_auth().route_seq(
850 "/api/feed/v3",
851 vec![
852 Reply::json(&one_clip_page("a", Some("cur1"))),
853 Reply::json(&one_clip_page("e", None)),
854 ],
855 );
856 let clock = RecordingClock::new();
857 let mut client = scripted_client(&http, clock.clone());
858
859 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
860 assert!(complete);
861 assert_eq!(clips.len(), 2);
862 assert_eq!(http.count("/api/feed/v3"), 2);
863 assert!(clock.sleeps().is_empty());
865 }
866
867 #[test]
868 fn list_clips_slows_its_pace_after_a_throttled_page() {
869 let http = ScriptedHttp::new().with_auth().route_seq(
870 "/api/feed/v3",
871 vec![
872 Reply::status(429),
873 Reply::json(&one_clip_page("a", Some("cur1"))),
874 Reply::json(&one_clip_page("e", None)),
875 ],
876 );
877 let clock = RecordingClock::new();
878 let mut client = scripted_client(&http, clock.clone());
879
880 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
881 assert!(complete);
882 assert_eq!(clips.len(), 2);
883 assert_eq!(
886 clock.sleeps(),
887 vec![Duration::from_secs(5), Duration::from_secs(1)]
888 );
889 }
890
891 #[test]
892 fn list_clips_gives_up_after_max_retries() {
893 let http = ScriptedHttp::new()
894 .with_auth()
895 .route("/api/feed/v3", Reply::status(429));
896 let clock = RecordingClock::new();
897 let mut client = scripted_client(&http, clock.clone());
898
899 let result = pollster::block_on(client.list_clips(&http, false, None));
900 assert!(matches!(result, Err(Error::RateLimited { .. })));
901 let budget = crate::consts::API_MAX_RETRIES as usize;
902 assert_eq!(clock.sleeps().len(), budget);
903 assert_eq!(http.count("/api/feed/v3"), budget + 1);
904 }
905
906 #[test]
907 fn parse_clip_accepts_bare_and_wrapped_shapes() {
908 let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
909 assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
910
911 let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
912 assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
913
914 let missing = serde_json::json!({"detail": "not found"}).to_string();
915 assert!(parse_clip(missing.as_bytes()).is_none());
916 }
917
918 #[test]
919 fn get_clip_uses_the_dedicated_endpoint() {
920 let clip_body = serde_json::json!({
921 "id": "z", "title": "Zed", "status": "complete",
922 "audio_url": "https://cdn1.suno.ai/z.mp3",
923 "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
924 })
925 .to_string();
926 let mut rules = auth_rules();
927 rules.push(Rule::new("/api/clip/", 200, clip_body));
928 let http = MockHttp::new(rules);
929 let mut client = authed_client(&http);
930
931 let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
932 assert_eq!(clip.id, "z");
933 assert_eq!(clip.title, "Zed");
934 assert_eq!(clip.tags, "jazz");
935 }
936
937 #[test]
938 fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
939 let mut rules = auth_rules();
940 rules.push(Rule::new(
941 "/api/clip/",
942 404,
943 r#"{"detail": "not found"}"#.to_string(),
944 ));
945 rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
946 let http = MockHttp::new(rules);
947 let mut client = authed_client(&http);
948
949 let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
950 assert_eq!(clip.id, "a");
951 assert_eq!(clip.tags, "rock");
952 }
953
954 #[test]
955 fn request_wav_accepts_a_2xx_status() {
956 let mut rules = auth_rules();
957 rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
958 let http = MockHttp::new(rules);
959 let mut client = authed_client(&http);
960
961 assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
962 }
963
964 #[test]
965 fn wav_url_reads_the_ready_url() {
966 let mut rules = auth_rules();
967 rules.push(Rule::new(
968 "/wav_file/",
969 200,
970 r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
971 ));
972 let http = MockHttp::new(rules);
973 let mut client = authed_client(&http);
974
975 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
976 assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
977 }
978
979 #[test]
980 fn wav_url_is_none_until_the_render_is_ready() {
981 let mut rules = auth_rules();
982 rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
983 let http = MockHttp::new(rules);
984 let mut client = authed_client(&http);
985
986 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
987 assert_eq!(url, None);
988 }
989
990 #[test]
991 fn get_clips_by_ids_fetches_each_id_and_keeps_artefacts() {
992 let p1 = serde_json::json!({
996 "id": "p1", "title": "Infill Ancestor", "status": "complete",
997 "metadata": {"type": "gen", "task": "infill"}
998 })
999 .to_string();
1000 let p2 = serde_json::json!({
1001 "id": "p2", "title": "Uploaded Root", "status": "complete",
1002 "metadata": {"type": "upload"}
1003 })
1004 .to_string();
1005 let mut rules = auth_rules();
1006 rules.push(Rule::new("/api/clip/p1", 200, p1));
1007 rules.push(Rule::new("/api/clip/p2", 200, p2));
1008 let http = MockHttp::new(rules);
1009 let mut client = authed_client(&http);
1010
1011 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"])).unwrap();
1012 assert_eq!(
1013 clips.len(),
1014 2,
1015 "infill and upload ancestors must not be filtered"
1016 );
1017 assert_eq!(clips[0].id, "p1");
1018 assert_eq!(clips[1].id, "p2");
1019 }
1020
1021 #[test]
1022 fn get_clips_by_ids_returns_a_trashed_clip() {
1023 let trashed = serde_json::json!({
1026 "id": "t1", "title": "Trashed Ancestor", "status": "complete",
1027 "is_trashed": true, "metadata": {"type": "gen"}
1028 })
1029 .to_string();
1030 let mut rules = auth_rules();
1031 rules.push(Rule::new("/api/clip/t1", 200, trashed));
1032 let http = MockHttp::new(rules);
1033 let mut client = authed_client(&http);
1034
1035 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"])).unwrap();
1036 assert_eq!(clips.len(), 1);
1037 assert_eq!(clips[0].id, "t1");
1038 assert!(clips[0].is_trashed);
1039 }
1040
1041 #[test]
1042 fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
1043 let only = serde_json::json!({
1044 "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
1045 })
1046 .to_string();
1047 let http = ScriptedHttp::new()
1048 .with_auth()
1049 .route("/api/clip/gone", Reply::status(404))
1050 .route("/api/clip/only", Reply::json(&only));
1051 let mut client = scripted_client(&http, RecordingClock::new());
1052
1053 let clips =
1054 pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"])).unwrap();
1055 assert_eq!(clips.len(), 1, "the 404 id is skipped");
1056 assert_eq!(clips[0].id, "only");
1057 assert_eq!(http.count("/api/clip/only"), 1);
1059 assert_eq!(http.count("/api/clip/gone"), 1);
1060 }
1061
1062 #[test]
1063 fn get_clip_parent_reads_the_parent_clip() {
1064 let parent = serde_json::json!({
1065 "id": "par", "title": "Ancestor", "status": "complete",
1066 "metadata": {"type": "gen"}
1067 })
1068 .to_string();
1069 let mut rules = auth_rules();
1070 rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
1071 let http = MockHttp::new(rules);
1072 let mut client = authed_client(&http);
1073
1074 let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
1075 assert_eq!(clip.unwrap().id, "par");
1076 }
1077
1078 #[test]
1079 fn get_clip_parent_is_none_for_a_root() {
1080 let mut rules = auth_rules();
1081 rules.push(Rule::new(
1082 "/api/clips/parent",
1083 404,
1084 r#"{"detail": "no parent"}"#.to_string(),
1085 ));
1086 let http = MockHttp::new(rules);
1087 let mut client = authed_client(&http);
1088
1089 let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
1090 assert!(clip.is_none());
1091 }
1092
1093 #[test]
1094 fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
1095 for status in [500u16, 503] {
1099 let mut rules = auth_rules();
1100 rules.push(Rule::new(
1101 "/api/clips/parent",
1102 status,
1103 r#"{"detail": "server error"}"#.to_string(),
1104 ));
1105 let http = MockHttp::new(rules);
1106 let mut client = authed_client(&http);
1107
1108 let result = pollster::block_on(client.get_clip_parent(&http, "child"));
1109 assert!(
1110 matches!(result, Err(Error::Api(_))),
1111 "status {status} must propagate as an error, not Ok(None)"
1112 );
1113 }
1114 }
1115
1116 #[test]
1117 fn get_playlists_maps_entries_and_skips_missing_ids() {
1118 let page1 = serde_json::json!({
1119 "playlists": [
1120 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
1121 {"id": "", "name": "No Id", "num_total_results": 3},
1122 {"name": "Also No Id"}
1123 ]
1124 })
1125 .to_string();
1126 let mut rules = auth_rules();
1127 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1129 rules.push(Rule::new(
1130 "/api/playlist/me?page=2",
1131 200,
1132 r#"{"playlists": []}"#.to_string(),
1133 ));
1134 let http = MockHttp::new(rules);
1135 let mut client = authed_client(&http);
1136
1137 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1138 assert_eq!(playlists.len(), 1, "entries without an id are dropped");
1139 assert_eq!(
1140 playlists[0],
1141 Playlist {
1142 id: "pl1".to_owned(),
1143 name: "Road Trip".to_owned(),
1144 num_clips: 12,
1145 }
1146 );
1147 }
1148
1149 #[test]
1150 fn get_playlists_defaults_a_missing_name_to_untitled() {
1151 let page1 = serde_json::json!({
1152 "playlists": [{"id": "pl9", "num_total_results": 1}]
1153 })
1154 .to_string();
1155 let mut rules = auth_rules();
1156 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1157 rules.push(Rule::new(
1158 "/api/playlist/me?page=2",
1159 200,
1160 r#"{"playlists": []}"#.to_string(),
1161 ));
1162 let http = MockHttp::new(rules);
1163 let mut client = authed_client(&http);
1164
1165 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1166 assert_eq!(playlists[0].name, "Untitled");
1167 }
1168
1169 #[test]
1170 fn get_playlist_clips_preserves_order_and_unwraps_clip() {
1171 let body = serde_json::json!({
1174 "playlist_clips": [
1175 {"clip": {
1176 "id": "second", "title": "Second", "status": "complete",
1177 "metadata": {"duration": 60.0, "type": "gen"}
1178 }},
1179 {"clip": {
1180 "id": "first", "title": "First", "status": "complete",
1181 "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
1182 }}
1183 ]
1184 })
1185 .to_string();
1186 let mut rules = auth_rules();
1187 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1188 let http = MockHttp::new(rules);
1189 let mut client = authed_client(&http);
1190
1191 let clips = pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1192 assert_eq!(clips.len(), 2, "an infill member is not filtered out");
1193 assert_eq!(clips[0].id, "second");
1194 assert_eq!(clips[1].id, "first");
1195 }
1196
1197 #[test]
1198 fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
1199 let mut rules = auth_rules();
1200 rules.push(Rule::new(
1201 "/api/playlist/empty/",
1202 200,
1203 r#"{"playlist_clips": []}"#.to_string(),
1204 ));
1205 let http = MockHttp::new(rules);
1206 let mut client = authed_client(&http);
1207
1208 let clips = pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
1209 assert!(clips.is_empty());
1210 }
1211}