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(
240 &mut self,
241 http: &impl Http,
242 id: &str,
243 ) -> Result<(Vec<Clip>, bool)> {
244 let path = format!("{PLAYLIST_PATH}{id}/");
245 let body = self.api_get_retrying(http, &path).await?;
246 parse_playlist_clips(&body)
247 }
248
249 async fn try_get_clip(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
252 let path = format!("/api/clip/{id}");
253 match self.api_get_retrying(http, &path).await {
254 Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
255 Err(Error::NotFound(_)) => Ok(None),
256 Err(err) => Err(err),
257 }
258 }
259
260 async fn find_in_feed(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
262 let (clips, _complete) = self.list_clips(http, false, None).await?;
263 clips
264 .into_iter()
265 .find(|clip| clip.id == id)
266 .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
267 }
268
269 async fn api_get(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
271 self.api_request(http, Method::Get, path, Vec::new()).await
272 }
273
274 async fn api_get_retrying(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
276 self.api_send_retrying(http, Method::Get, path, Vec::new())
277 .await
278 }
279
280 async fn api_send_retrying(
299 &mut self,
300 http: &impl Http,
301 method: Method,
302 path: &str,
303 body: Vec<u8>,
304 ) -> Result<Vec<u8>> {
305 let pace = self.limiter.pace();
306 if !pace.is_zero() {
307 self.clock.sleep(pace).await;
308 }
309 let mut retries = 0;
310 loop {
311 match self.api_request(http, method, path, body.clone()).await {
312 Ok(response) => return Ok(response),
313 Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
314 self.clock.sleep(retry_after_delay(retry_after)).await;
315 retries += 1;
316 }
317 Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
318 self.clock.sleep(backoff_delay(retries, None)).await;
319 retries += 1;
320 }
321 Err(err) => return Err(err),
322 }
323 }
324 }
325
326 async fn api_request(
331 &mut self,
332 http: &impl Http,
333 method: Method,
334 path: &str,
335 body: Vec<u8>,
336 ) -> Result<Vec<u8>> {
337 let url = format!("{SUNO_API_BASE_URL}{path}");
338 let mut auth_refreshed = false;
339 loop {
340 let jwt = self.auth.ensure_jwt(http).await?;
341 let mut request = match method {
342 Method::Get => HttpRequest::get(url.clone()),
343 Method::Post => HttpRequest::post(url.clone(), body.clone()),
344 };
345 request
346 .headers
347 .push(("Authorization".to_string(), format!("Bearer {jwt}")));
348 let response = http
349 .send(request)
350 .await
351 .map_err(|err| Error::Connection(err.to_string()))?;
352 match response.status {
353 200..=299 => {
354 self.limiter.on_success();
355 return Ok(response.body);
356 }
357 401 | 403 if !auth_refreshed => {
358 self.auth.invalidate_jwt();
359 auth_refreshed = true;
360 }
361 401 | 403 => {
362 return Err(Error::Auth(format!(
363 "Suno API auth failed with status {}",
364 response.status
365 )));
366 }
367 429 => {
368 self.limiter.on_rate_limit();
369 return Err(Error::RateLimited {
370 retry_after: retry_after(&response),
371 });
372 }
373 404 => {
374 return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
375 }
376 status => {
377 let preview: String = String::from_utf8_lossy(&response.body)
378 .chars()
379 .take(200)
380 .collect();
381 return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
382 }
383 }
384 }
385 }
386}
387
388fn parse_clip(body: &[u8]) -> Option<Clip> {
391 let data: Value = serde_json::from_slice(body).ok()?;
392 let raw = data
393 .get("clip")
394 .filter(|value| value.is_object())
395 .unwrap_or(&data);
396 let has_id = raw
397 .get("id")
398 .and_then(Value::as_str)
399 .is_some_and(|id| !id.is_empty());
400 has_id.then(|| Clip::from_json(raw))
401}
402
403fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
410 let mut filters = serde_json::Map::new();
411 filters.insert("trashed".to_string(), Value::String("False".to_string()));
412 if liked {
413 filters.insert("liked".to_string(), Value::String("True".to_string()));
414 }
415 let mut body = serde_json::Map::new();
416 body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
417 body.insert("filters".to_string(), Value::Object(filters));
418 if let Some(cursor) = cursor {
419 body.insert("cursor".to_string(), Value::String(cursor.to_string()));
420 }
421 serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
422}
423
424fn parse_feed_v3(body: &[u8]) -> Result<(Vec<Clip>, Option<bool>, Option<String>)> {
431 let data: Value = serde_json::from_slice(body)
432 .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
433 let Some(object) = data.as_object() else {
434 return Ok((Vec::new(), None, None));
435 };
436 let clips = object
437 .get("clips")
438 .and_then(Value::as_array)
439 .map(|raw| {
440 raw.iter()
441 .map(Clip::from_json)
442 .filter(is_downloadable)
443 .collect()
444 })
445 .unwrap_or_default();
446 let has_more = object.get("has_more").and_then(Value::as_bool);
447 let next_cursor = object
448 .get("next_cursor")
449 .and_then(Value::as_str)
450 .filter(|cursor| !cursor.is_empty())
451 .map(str::to_string);
452 Ok((clips, has_more, next_cursor))
453}
454
455fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
457 let data: Value = serde_json::from_slice(body)
458 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
459 Ok(data
460 .get("playlists")
461 .and_then(Value::as_array)
462 .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
463 .unwrap_or_default())
464}
465
466fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
471 let id = raw
472 .get("id")
473 .and_then(Value::as_str)
474 .filter(|id| !id.is_empty())?
475 .to_string();
476 let name = match raw.get("name") {
477 Some(Value::String(name)) if !name.is_empty() => name.clone(),
478 _ => "Untitled".to_string(),
479 };
480 let num_clips = raw
481 .get("num_total_results")
482 .and_then(Value::as_u64)
483 .unwrap_or(0);
484 Some(Playlist {
485 id,
486 name,
487 num_clips,
488 })
489}
490
491fn parse_playlist_clips(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
508 let data: Value = serde_json::from_slice(body)
509 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
510 let raw = data.get("playlist_clips").and_then(Value::as_array);
511 let raw_len = raw.map(|a| a.len()).unwrap_or(0);
512 let clips: Vec<Clip> = raw
513 .map(|raw| {
514 raw.iter()
515 .map(|entry| {
516 let clip = entry
517 .get("clip")
518 .filter(|value| value.is_object())
519 .unwrap_or(entry);
520 Clip::from_json(clip)
521 })
522 .filter(|clip| !clip.id.is_empty())
523 .collect()
524 })
525 .unwrap_or_default();
526 let complete = data
532 .get("num_total_results")
533 .and_then(Value::as_u64)
534 .is_some_and(|total| raw_len as u64 == total);
535 Ok((clips, complete))
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541 use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
542 use std::time::Duration;
543
544 fn feed_body() -> String {
545 serde_json::json!({
546 "has_more": false,
547 "clips": [
548 {
549 "id": "a", "title": "Song A", "status": "complete",
550 "audio_url": "https://cdn1.suno.ai/a.mp3",
551 "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
552 },
553 {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
554 {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
555 {
556 "id": "d", "title": "Context", "status": "complete",
557 "metadata": {"type": "rendered_context_window"}
558 }
559 ]
560 })
561 .to_string()
562 }
563
564 #[test]
565 fn parse_feed_v3_filters_and_reads_pagination() {
566 let (clips, has_more, next_cursor) = parse_feed_v3(feed_body().as_bytes()).unwrap();
567 assert_eq!(has_more, Some(false));
568 assert_eq!(next_cursor, None);
569 assert_eq!(clips.len(), 1);
570 assert_eq!(clips[0].id, "a");
571 assert_eq!(clips[0].tags, "rock");
572 assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
573 }
574
575 #[test]
576 fn feed_v3_body_carries_filters_and_optional_cursor() {
577 let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
578 assert_eq!(first["filters"]["trashed"], "False");
579 assert!(first.get("cursor").is_none());
580 assert!(first["filters"].get("liked").is_none());
581
582 let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
583 assert_eq!(liked["filters"]["liked"], "True");
584 assert_eq!(liked["cursor"], "cur42");
585 }
586
587 #[test]
588 fn audiopipe_url_is_rewritten_to_cdn() {
589 let raw =
590 serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
591 assert_eq!(
592 Clip::from_json(&raw).audio_url,
593 "https://cdn1.suno.ai/x.mp3"
594 );
595 }
596
597 #[test]
598 fn list_clips_authenticates_then_reads_the_feed() {
599 let client_body = serde_json::json!({
600 "response": {
601 "last_active_session_id": "s",
602 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
603 }
604 })
605 .to_string();
606 let http = MockHttp::new(vec![
607 Rule::new(
608 "/v1/client/sessions/",
609 200,
610 r#"{"jwt": "a.b.c"}"#.to_string(),
611 ),
612 Rule::new("/v1/client", 200, client_body),
613 Rule::new("/api/feed/v3", 200, feed_body()),
614 ]);
615
616 let mut auth = ClerkAuth::new("eyJtoken");
617 pollster::block_on(auth.authenticate(&http)).unwrap();
618 let mut client = SunoClient::new(auth, RecordingClock::new());
619 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
620 assert_eq!(clips.len(), 1);
621 assert_eq!(clips[0].id, "a");
622 assert!(complete);
623 }
624
625 #[test]
626 fn list_clips_reports_incomplete_when_paging_is_capped() {
627 let mut rules = auth_rules();
628 rules.push(Rule::new(
629 "/api/feed/v3",
630 200,
631 serde_json::json!({
632 "has_more": true,
633 "next_cursor": "cur1",
634 "clips": [{
635 "id": "a", "title": "Song A", "status": "complete",
636 "audio_url": "https://cdn1.suno.ai/a.mp3",
637 "metadata": {"type": "gen"}
638 }]
639 })
640 .to_string(),
641 ));
642 let http = MockHttp::new(rules);
643 let mut client = authed_client(&http);
644
645 let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
646 assert!(!complete);
647 }
648
649 fn auth_rules() -> Vec<Rule> {
650 let client_body = serde_json::json!({
651 "response": {
652 "last_active_session_id": "s",
653 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
654 }
655 })
656 .to_string();
657 vec![
658 Rule::new(
659 "/v1/client/sessions/",
660 200,
661 r#"{"jwt": "a.b.c"}"#.to_string(),
662 ),
663 Rule::new("/v1/client", 200, client_body),
664 ]
665 }
666
667 fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
668 let mut auth = ClerkAuth::new("eyJtoken");
669 pollster::block_on(auth.authenticate(http)).unwrap();
670 SunoClient::new(auth, RecordingClock::new())
671 }
672
673 fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
674 let mut auth = ClerkAuth::new("eyJtoken");
675 pollster::block_on(auth.authenticate(http)).unwrap();
676 SunoClient::new(auth, clock)
677 }
678
679 fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
680 let mut page = serde_json::json!({
681 "has_more": next_cursor.is_some(),
682 "clips": [{
683 "id": id, "title": "Song", "status": "complete",
684 "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
685 "metadata": {"type": "gen"}
686 }]
687 });
688 if let Some(cursor) = next_cursor {
689 page["next_cursor"] = serde_json::json!(cursor);
690 }
691 page.to_string()
692 }
693
694 #[test]
695 fn list_clips_retries_a_rate_limited_page() {
696 let http = ScriptedHttp::new().with_auth().route_seq(
697 "/api/feed/v3",
698 vec![Reply::status(429), Reply::json(&feed_body())],
699 );
700 let clock = RecordingClock::new();
701 let mut client = scripted_client(&http, clock.clone());
702
703 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
704 assert_eq!(clips.len(), 1);
705 assert!(complete);
706 assert_eq!(http.count("/api/feed/v3"), 2);
708 assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
709 }
710
711 #[test]
712 fn list_clips_honours_retry_after_on_a_throttled_page() {
713 let http = ScriptedHttp::new().with_auth().route_seq(
714 "/api/feed/v3",
715 vec![
716 Reply::status(429).with_retry_after(7),
717 Reply::json(&feed_body()),
718 ],
719 );
720 let clock = RecordingClock::new();
721 let mut client = scripted_client(&http, clock.clone());
722
723 let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
724 assert_eq!(clips.len(), 1);
725 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
727 }
728
729 #[test]
730 fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
731 let http = ScriptedHttp::new().with_auth().route_seq(
733 "/api/feed/v3",
734 vec![
735 Reply::json(&one_clip_page("a", Some("cur1"))),
736 Reply::status(429),
737 Reply::json(&one_clip_page("b", None)),
738 ],
739 );
740 let clock = RecordingClock::new();
741 let mut client = scripted_client(&http, clock.clone());
742
743 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
744 assert!(complete);
745 assert_eq!(clips.len(), 2);
746 let bodies = http.bodies();
747 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
748 assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
749 let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
752 let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
753 assert_eq!(retried["cursor"], "cur1");
754 assert_eq!(after_retry["cursor"], "cur1");
755 }
756
757 #[test]
758 fn list_clips_threads_the_cursor_across_pages() {
759 let http = ScriptedHttp::new().with_auth().route_seq(
760 "/api/feed/v3",
761 vec![
762 Reply::json(&one_clip_page("a", Some("cur1"))),
763 Reply::json(&one_clip_page("b", None)),
764 ],
765 );
766 let clock = RecordingClock::new();
767 let mut client = scripted_client(&http, clock.clone());
768
769 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
770 assert!(complete);
771 assert_eq!(clips.len(), 2);
772 let bodies = http.bodies();
773 let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
774 assert_eq!(feed_bodies.len(), 2);
775 let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
776 let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
777 assert!(page1.get("cursor").is_none());
779 assert_eq!(page2["cursor"], "cur1");
780 }
781
782 #[test]
783 fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
784 let page = serde_json::json!({
787 "has_more": true,
788 "clips": [{
789 "id": "a", "title": "Song", "status": "complete",
790 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
791 }]
792 })
793 .to_string();
794 let http = ScriptedHttp::new()
795 .with_auth()
796 .route("/api/feed/v3", Reply::json(&page));
797 let clock = RecordingClock::new();
798 let mut client = scripted_client(&http, clock.clone());
799
800 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
801 assert!(!complete);
802 assert_eq!(clips.len(), 1);
803 assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
804 }
805
806 #[test]
807 fn list_clips_is_incomplete_when_has_more_is_missing() {
808 let page = serde_json::json!({
810 "clips": [{
811 "id": "a", "title": "Song", "status": "complete",
812 "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
813 }]
814 })
815 .to_string();
816 let http = ScriptedHttp::new()
817 .with_auth()
818 .route("/api/feed/v3", Reply::json(&page));
819 let clock = RecordingClock::new();
820 let mut client = scripted_client(&http, clock.clone());
821
822 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
823 assert!(!complete);
824 assert_eq!(clips.len(), 1);
825 assert_eq!(http.count("/api/feed/v3"), 1);
826 }
827
828 #[test]
829 fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
830 let http = ScriptedHttp::new().with_auth().route_seq(
831 "/api/feed/v3",
832 vec![
833 Reply::json(&one_clip_page("a", Some("cur1"))),
834 Reply::status(500),
835 ],
836 );
837 let clock = RecordingClock::new();
838 let mut client = scripted_client(&http, clock.clone());
839
840 let result = pollster::block_on(client.list_clips(&http, false, None));
841 assert!(matches!(result, Err(Error::Api(_))));
842 }
843
844 #[test]
845 fn list_clips_is_complete_on_an_empty_drained_feed() {
846 let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
849 let http = ScriptedHttp::new()
850 .with_auth()
851 .route("/api/feed/v3", Reply::json(&page));
852 let clock = RecordingClock::new();
853 let mut client = scripted_client(&http, clock.clone());
854
855 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
856 assert!(complete);
857 assert!(clips.is_empty());
858 }
859
860 #[test]
861 fn list_clips_liked_scope_sends_the_liked_filter() {
862 let http = ScriptedHttp::new()
863 .with_auth()
864 .route("/api/feed/v3", Reply::json(&feed_body()));
865 let clock = RecordingClock::new();
866 let mut client = scripted_client(&http, clock.clone());
867
868 let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
869 let bodies = http.bodies();
870 let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
871 let value: Value = serde_json::from_str(feed_body).unwrap();
872 assert_eq!(value["filters"]["liked"], "True");
873 assert_eq!(value["filters"]["trashed"], "False");
874 }
875
876 #[test]
877 fn list_clips_does_not_pace_an_unthrottled_walk() {
878 let http = ScriptedHttp::new().with_auth().route_seq(
879 "/api/feed/v3",
880 vec![
881 Reply::json(&one_clip_page("a", Some("cur1"))),
882 Reply::json(&one_clip_page("e", None)),
883 ],
884 );
885 let clock = RecordingClock::new();
886 let mut client = scripted_client(&http, clock.clone());
887
888 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
889 assert!(complete);
890 assert_eq!(clips.len(), 2);
891 assert_eq!(http.count("/api/feed/v3"), 2);
892 assert!(clock.sleeps().is_empty());
894 }
895
896 #[test]
897 fn list_clips_slows_its_pace_after_a_throttled_page() {
898 let http = ScriptedHttp::new().with_auth().route_seq(
899 "/api/feed/v3",
900 vec![
901 Reply::status(429),
902 Reply::json(&one_clip_page("a", Some("cur1"))),
903 Reply::json(&one_clip_page("e", None)),
904 ],
905 );
906 let clock = RecordingClock::new();
907 let mut client = scripted_client(&http, clock.clone());
908
909 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
910 assert!(complete);
911 assert_eq!(clips.len(), 2);
912 assert_eq!(
915 clock.sleeps(),
916 vec![Duration::from_secs(5), Duration::from_secs(1)]
917 );
918 }
919
920 #[test]
921 fn list_clips_gives_up_after_max_retries() {
922 let http = ScriptedHttp::new()
923 .with_auth()
924 .route("/api/feed/v3", Reply::status(429));
925 let clock = RecordingClock::new();
926 let mut client = scripted_client(&http, clock.clone());
927
928 let result = pollster::block_on(client.list_clips(&http, false, None));
929 assert!(matches!(result, Err(Error::RateLimited { .. })));
930 let budget = crate::consts::API_MAX_RETRIES as usize;
931 assert_eq!(clock.sleeps().len(), budget);
932 assert_eq!(http.count("/api/feed/v3"), budget + 1);
933 }
934
935 #[test]
936 fn parse_clip_accepts_bare_and_wrapped_shapes() {
937 let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
938 assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
939
940 let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
941 assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
942
943 let missing = serde_json::json!({"detail": "not found"}).to_string();
944 assert!(parse_clip(missing.as_bytes()).is_none());
945 }
946
947 #[test]
948 fn get_clip_uses_the_dedicated_endpoint() {
949 let clip_body = serde_json::json!({
950 "id": "z", "title": "Zed", "status": "complete",
951 "audio_url": "https://cdn1.suno.ai/z.mp3",
952 "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
953 })
954 .to_string();
955 let mut rules = auth_rules();
956 rules.push(Rule::new("/api/clip/", 200, clip_body));
957 let http = MockHttp::new(rules);
958 let mut client = authed_client(&http);
959
960 let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
961 assert_eq!(clip.id, "z");
962 assert_eq!(clip.title, "Zed");
963 assert_eq!(clip.tags, "jazz");
964 }
965
966 #[test]
967 fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
968 let mut rules = auth_rules();
969 rules.push(Rule::new(
970 "/api/clip/",
971 404,
972 r#"{"detail": "not found"}"#.to_string(),
973 ));
974 rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
975 let http = MockHttp::new(rules);
976 let mut client = authed_client(&http);
977
978 let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
979 assert_eq!(clip.id, "a");
980 assert_eq!(clip.tags, "rock");
981 }
982
983 #[test]
984 fn request_wav_accepts_a_2xx_status() {
985 let mut rules = auth_rules();
986 rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
987 let http = MockHttp::new(rules);
988 let mut client = authed_client(&http);
989
990 assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
991 }
992
993 #[test]
994 fn wav_url_reads_the_ready_url() {
995 let mut rules = auth_rules();
996 rules.push(Rule::new(
997 "/wav_file/",
998 200,
999 r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
1000 ));
1001 let http = MockHttp::new(rules);
1002 let mut client = authed_client(&http);
1003
1004 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1005 assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
1006 }
1007
1008 #[test]
1009 fn wav_url_is_none_until_the_render_is_ready() {
1010 let mut rules = auth_rules();
1011 rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
1012 let http = MockHttp::new(rules);
1013 let mut client = authed_client(&http);
1014
1015 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1016 assert_eq!(url, None);
1017 }
1018
1019 #[test]
1020 fn get_clips_by_ids_fetches_each_id_and_keeps_artefacts() {
1021 let p1 = serde_json::json!({
1025 "id": "p1", "title": "Infill Ancestor", "status": "complete",
1026 "metadata": {"type": "gen", "task": "infill"}
1027 })
1028 .to_string();
1029 let p2 = serde_json::json!({
1030 "id": "p2", "title": "Uploaded Root", "status": "complete",
1031 "metadata": {"type": "upload"}
1032 })
1033 .to_string();
1034 let mut rules = auth_rules();
1035 rules.push(Rule::new("/api/clip/p1", 200, p1));
1036 rules.push(Rule::new("/api/clip/p2", 200, p2));
1037 let http = MockHttp::new(rules);
1038 let mut client = authed_client(&http);
1039
1040 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"])).unwrap();
1041 assert_eq!(
1042 clips.len(),
1043 2,
1044 "infill and upload ancestors must not be filtered"
1045 );
1046 assert_eq!(clips[0].id, "p1");
1047 assert_eq!(clips[1].id, "p2");
1048 }
1049
1050 #[test]
1051 fn get_clips_by_ids_returns_a_trashed_clip() {
1052 let trashed = serde_json::json!({
1055 "id": "t1", "title": "Trashed Ancestor", "status": "complete",
1056 "is_trashed": true, "metadata": {"type": "gen"}
1057 })
1058 .to_string();
1059 let mut rules = auth_rules();
1060 rules.push(Rule::new("/api/clip/t1", 200, trashed));
1061 let http = MockHttp::new(rules);
1062 let mut client = authed_client(&http);
1063
1064 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"])).unwrap();
1065 assert_eq!(clips.len(), 1);
1066 assert_eq!(clips[0].id, "t1");
1067 assert!(clips[0].is_trashed);
1068 }
1069
1070 #[test]
1071 fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
1072 let only = serde_json::json!({
1073 "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
1074 })
1075 .to_string();
1076 let http = ScriptedHttp::new()
1077 .with_auth()
1078 .route("/api/clip/gone", Reply::status(404))
1079 .route("/api/clip/only", Reply::json(&only));
1080 let mut client = scripted_client(&http, RecordingClock::new());
1081
1082 let clips =
1083 pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"])).unwrap();
1084 assert_eq!(clips.len(), 1, "the 404 id is skipped");
1085 assert_eq!(clips[0].id, "only");
1086 assert_eq!(http.count("/api/clip/only"), 1);
1088 assert_eq!(http.count("/api/clip/gone"), 1);
1089 }
1090
1091 #[test]
1092 fn get_clip_parent_reads_the_parent_clip() {
1093 let parent = serde_json::json!({
1094 "id": "par", "title": "Ancestor", "status": "complete",
1095 "metadata": {"type": "gen"}
1096 })
1097 .to_string();
1098 let mut rules = auth_rules();
1099 rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
1100 let http = MockHttp::new(rules);
1101 let mut client = authed_client(&http);
1102
1103 let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
1104 assert_eq!(clip.unwrap().id, "par");
1105 }
1106
1107 #[test]
1108 fn get_clip_parent_is_none_for_a_root() {
1109 let mut rules = auth_rules();
1110 rules.push(Rule::new(
1111 "/api/clips/parent",
1112 404,
1113 r#"{"detail": "no parent"}"#.to_string(),
1114 ));
1115 let http = MockHttp::new(rules);
1116 let mut client = authed_client(&http);
1117
1118 let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
1119 assert!(clip.is_none());
1120 }
1121
1122 #[test]
1123 fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
1124 for status in [500u16, 503] {
1128 let mut rules = auth_rules();
1129 rules.push(Rule::new(
1130 "/api/clips/parent",
1131 status,
1132 r#"{"detail": "server error"}"#.to_string(),
1133 ));
1134 let http = MockHttp::new(rules);
1135 let mut client = authed_client(&http);
1136
1137 let result = pollster::block_on(client.get_clip_parent(&http, "child"));
1138 assert!(
1139 matches!(result, Err(Error::Api(_))),
1140 "status {status} must propagate as an error, not Ok(None)"
1141 );
1142 }
1143 }
1144
1145 #[test]
1146 fn get_playlists_maps_entries_and_skips_missing_ids() {
1147 let page1 = serde_json::json!({
1148 "playlists": [
1149 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
1150 {"id": "", "name": "No Id", "num_total_results": 3},
1151 {"name": "Also No Id"}
1152 ]
1153 })
1154 .to_string();
1155 let mut rules = auth_rules();
1156 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1158 rules.push(Rule::new(
1159 "/api/playlist/me?page=2",
1160 200,
1161 r#"{"playlists": []}"#.to_string(),
1162 ));
1163 let http = MockHttp::new(rules);
1164 let mut client = authed_client(&http);
1165
1166 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1167 assert_eq!(playlists.len(), 1, "entries without an id are dropped");
1168 assert_eq!(
1169 playlists[0],
1170 Playlist {
1171 id: "pl1".to_owned(),
1172 name: "Road Trip".to_owned(),
1173 num_clips: 12,
1174 }
1175 );
1176 }
1177
1178 #[test]
1179 fn get_playlists_defaults_a_missing_name_to_untitled() {
1180 let page1 = serde_json::json!({
1181 "playlists": [{"id": "pl9", "num_total_results": 1}]
1182 })
1183 .to_string();
1184 let mut rules = auth_rules();
1185 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1186 rules.push(Rule::new(
1187 "/api/playlist/me?page=2",
1188 200,
1189 r#"{"playlists": []}"#.to_string(),
1190 ));
1191 let http = MockHttp::new(rules);
1192 let mut client = authed_client(&http);
1193
1194 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1195 assert_eq!(playlists[0].name, "Untitled");
1196 }
1197
1198 #[test]
1199 fn get_playlist_clips_preserves_order_and_unwraps_clip() {
1200 let body = serde_json::json!({
1203 "num_total_results": 2,
1204 "playlist_clips": [
1205 {"clip": {
1206 "id": "second", "title": "Second", "status": "complete",
1207 "metadata": {"duration": 60.0, "type": "gen"}
1208 }},
1209 {"clip": {
1210 "id": "first", "title": "First", "status": "complete",
1211 "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
1212 }}
1213 ]
1214 })
1215 .to_string();
1216 let mut rules = auth_rules();
1217 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1218 let http = MockHttp::new(rules);
1219 let mut client = authed_client(&http);
1220
1221 let (clips, complete) =
1222 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1223 assert_eq!(clips.len(), 2, "an infill member is not filtered out");
1224 assert_eq!(clips[0].id, "second");
1225 assert_eq!(clips[1].id, "first");
1226 assert!(
1227 complete,
1228 "returned == num_total_results is fully enumerated"
1229 );
1230 }
1231
1232 #[test]
1233 fn get_playlist_clips_short_page_is_not_complete() {
1234 let body = serde_json::json!({
1236 "num_total_results": 5,
1237 "playlist_clips": [
1238 {"clip": {
1239 "id": "only", "title": "Only", "status": "complete",
1240 "metadata": {"duration": 60.0, "type": "gen"}
1241 }}
1242 ]
1243 })
1244 .to_string();
1245 let mut rules = auth_rules();
1246 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1247 let http = MockHttp::new(rules);
1248 let mut client = authed_client(&http);
1249
1250 let (clips, complete) =
1251 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1252 assert_eq!(clips.len(), 1);
1253 assert!(!complete, "a short page is not fully enumerated");
1254 }
1255
1256 #[test]
1257 fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
1258 let mut rules = auth_rules();
1259 rules.push(Rule::new(
1260 "/api/playlist/empty/",
1261 200,
1262 r#"{"num_total_results": 0, "playlist_clips": []}"#.to_string(),
1263 ));
1264 let http = MockHttp::new(rules);
1265 let mut client = authed_client(&http);
1266
1267 let (clips, complete) =
1268 pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
1269 assert!(clips.is_empty());
1270 assert!(
1271 complete,
1272 "an empty playlist reporting zero total is complete"
1273 );
1274 }
1275
1276 #[test]
1277 fn get_playlist_clips_missing_total_is_not_complete() {
1278 let mut rules = auth_rules();
1282 rules.push(Rule::new(
1283 "/api/playlist/pl1/",
1284 200,
1285 r#"{"playlist_clips": []}"#.to_string(),
1286 ));
1287 let http = MockHttp::new(rules);
1288 let mut client = authed_client(&http);
1289
1290 let (clips, complete) =
1291 pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1292 assert!(clips.is_empty());
1293 assert!(!complete, "a missing total is never fully enumerated");
1294 }
1295}