use std::collections::BTreeSet;
use serde_json::Value;
use crate::auth::ClerkAuth;
use crate::backoff::{backoff_delay, retry_after};
use crate::clock::Clock;
use crate::consts::{
API_MAX_RETRIES, CLIP_PARENT_PATH, FEED_PAGE_DELAY, FEED_PAGE_SIZE, FEED_V3_PATH, MAX_PAGES,
PLAYLIST_ME_PATH, PLAYLIST_PATH, SUNO_API_BASE_URL,
};
use crate::error::{Error, Result};
use crate::http::{Http, HttpRequest, Method};
use crate::is_downloadable;
use crate::model::Clip;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Playlist {
pub id: String,
pub name: String,
pub num_clips: u64,
}
pub struct SunoClient<C> {
auth: ClerkAuth,
clock: C,
}
impl<C: Clock> SunoClient<C> {
pub fn new(auth: ClerkAuth, clock: C) -> Self {
Self { auth, clock }
}
pub fn auth(&self) -> &ClerkAuth {
&self.auth
}
pub async fn list_clips(
&mut self,
http: &impl Http,
liked: bool,
limit: Option<usize>,
) -> Result<(Vec<Clip>, bool)> {
let mut clips = Vec::new();
let mut cursor: Option<String> = None;
let mut complete = false;
for page in 0..MAX_PAGES {
if page > 0 {
self.clock.sleep(FEED_PAGE_DELAY).await;
}
let body = feed_v3_body(liked, cursor.as_deref());
let response = self
.api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
.await?;
let (page_clips, has_more, next_cursor) = parse_feed_v3(&response)?;
clips.extend(page_clips);
match has_more {
Some(false) => {
complete = true;
break;
}
Some(true) => match next_cursor {
Some(next) => cursor = Some(next),
None => break,
},
None => break,
}
if limit.is_some_and(|n| clips.len() >= n) {
break;
}
}
if let Some(n) = limit {
clips.truncate(n);
}
Ok((clips, complete))
}
pub async fn get_clip(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
if let Some(clip) = self.try_get_clip(http, id).await? {
return Ok(clip);
}
self.find_in_feed(http, id).await
}
pub async fn request_wav(&mut self, http: &impl Http, id: &str) -> Result<()> {
let path = format!("/api/gen/{id}/convert_wav/");
self.api_request(http, Method::Post, &path, Vec::new())
.await?;
Ok(())
}
pub async fn wav_url(&mut self, http: &impl Http, id: &str) -> Result<Option<String>> {
let path = format!("/api/gen/{id}/wav_file/");
let body = self.api_get(http, &path).await?;
let data: Value = serde_json::from_slice(&body)
.map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
Ok(data
.get("wav_file_url")
.and_then(Value::as_str)
.filter(|url| !url.is_empty())
.map(str::to_string))
}
pub async fn get_clips_by_ids(&mut self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
let mut clips = Vec::new();
let mut seen: BTreeSet<&str> = BTreeSet::new();
for id in ids {
if id.is_empty() || !seen.insert(id) {
continue;
}
let path = format!("/api/clip/{id}");
match self.api_get_retrying(http, &path).await {
Ok(body) => {
if let Some(clip) = parse_clip(&body) {
clips.push(clip);
}
}
Err(Error::NotFound(_)) => continue,
Err(err) => return Err(err),
}
}
Ok(clips)
}
pub async fn get_clip_parent(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
match self.api_get_retrying(http, &path).await {
Ok(body) => Ok(parse_clip(&body)),
Err(Error::NotFound(_)) => Ok(None),
Err(err) => Err(err),
}
}
pub async fn get_playlists(&mut self, http: &impl Http) -> Result<Vec<Playlist>> {
let mut playlists = Vec::new();
for page in 1..=MAX_PAGES {
if page > 1 {
self.clock.sleep(FEED_PAGE_DELAY).await;
}
let path =
format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
let body = self.api_get_retrying(http, &path).await?;
let page_playlists = parse_playlists(&body)?;
if page_playlists.is_empty() {
break;
}
playlists.extend(page_playlists);
}
Ok(playlists)
}
pub async fn get_playlist_clips(&mut self, http: &impl Http, id: &str) -> Result<Vec<Clip>> {
let path = format!("{PLAYLIST_PATH}{id}/");
let body = self.api_get_retrying(http, &path).await?;
parse_playlist_clips(&body)
}
async fn try_get_clip(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
let path = format!("/api/clip/{id}");
match self.api_get_retrying(http, &path).await {
Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
Err(Error::NotFound(_)) => Ok(None),
Err(err) => Err(err),
}
}
async fn find_in_feed(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
let (clips, _complete) = self.list_clips(http, false, None).await?;
clips
.into_iter()
.find(|clip| clip.id == id)
.ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
}
async fn api_get(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
self.api_request(http, Method::Get, path, Vec::new()).await
}
async fn api_get_retrying(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
self.api_send_retrying(http, Method::Get, path, Vec::new())
.await
}
async fn api_send_retrying(
&mut self,
http: &impl Http,
method: Method,
path: &str,
body: Vec<u8>,
) -> Result<Vec<u8>> {
let mut retries = 0;
loop {
match self.api_request(http, method, path, body.clone()).await {
Ok(response) => return Ok(response),
Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
self.clock.sleep(backoff_delay(retries, retry_after)).await;
retries += 1;
}
Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
self.clock.sleep(backoff_delay(retries, None)).await;
retries += 1;
}
Err(err) => return Err(err),
}
}
}
async fn api_request(
&mut self,
http: &impl Http,
method: Method,
path: &str,
body: Vec<u8>,
) -> Result<Vec<u8>> {
let url = format!("{SUNO_API_BASE_URL}{path}");
let mut auth_refreshed = false;
loop {
let jwt = self.auth.ensure_jwt(http).await?;
let mut request = match method {
Method::Get => HttpRequest::get(url.clone()),
Method::Post => HttpRequest::post(url.clone(), body.clone()),
};
request
.headers
.push(("Authorization".to_string(), format!("Bearer {jwt}")));
let response = http
.send(request)
.await
.map_err(|err| Error::Connection(err.to_string()))?;
match response.status {
200..=299 => return Ok(response.body),
401 | 403 if !auth_refreshed => {
self.auth.invalidate_jwt();
auth_refreshed = true;
}
401 | 403 => {
return Err(Error::Auth(format!(
"Suno API auth failed with status {}",
response.status
)));
}
429 => {
return Err(Error::RateLimited {
retry_after: retry_after(&response),
});
}
404 => {
return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
}
status => {
let preview: String = String::from_utf8_lossy(&response.body)
.chars()
.take(200)
.collect();
return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
}
}
}
}
}
fn parse_clip(body: &[u8]) -> Option<Clip> {
let data: Value = serde_json::from_slice(body).ok()?;
let raw = data
.get("clip")
.filter(|value| value.is_object())
.unwrap_or(&data);
let has_id = raw
.get("id")
.and_then(Value::as_str)
.is_some_and(|id| !id.is_empty());
has_id.then(|| Clip::from_json(raw))
}
fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
let mut filters = serde_json::Map::new();
filters.insert("trashed".to_string(), Value::String("False".to_string()));
if liked {
filters.insert("liked".to_string(), Value::String("True".to_string()));
}
let mut body = serde_json::Map::new();
body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
body.insert("filters".to_string(), Value::Object(filters));
if let Some(cursor) = cursor {
body.insert("cursor".to_string(), Value::String(cursor.to_string()));
}
serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
}
fn parse_feed_v3(body: &[u8]) -> Result<(Vec<Clip>, Option<bool>, Option<String>)> {
let data: Value = serde_json::from_slice(body)
.map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
let Some(object) = data.as_object() else {
return Ok((Vec::new(), None, None));
};
let clips = object
.get("clips")
.and_then(Value::as_array)
.map(|raw| {
raw.iter()
.map(Clip::from_json)
.filter(is_downloadable)
.collect()
})
.unwrap_or_default();
let has_more = object.get("has_more").and_then(Value::as_bool);
let next_cursor = object
.get("next_cursor")
.and_then(Value::as_str)
.filter(|cursor| !cursor.is_empty())
.map(str::to_string);
Ok((clips, has_more, next_cursor))
}
fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
let data: Value = serde_json::from_slice(body)
.map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
Ok(data
.get("playlists")
.and_then(Value::as_array)
.map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
.unwrap_or_default())
}
fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
let id = raw
.get("id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())?
.to_string();
let name = match raw.get("name") {
Some(Value::String(name)) if !name.is_empty() => name.clone(),
_ => "Untitled".to_string(),
};
let num_clips = raw
.get("num_total_results")
.and_then(Value::as_u64)
.unwrap_or(0);
Some(Playlist {
id,
name,
num_clips,
})
}
fn parse_playlist_clips(body: &[u8]) -> Result<Vec<Clip>> {
let data: Value = serde_json::from_slice(body)
.map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
Ok(data
.get("playlist_clips")
.and_then(Value::as_array)
.map(|raw| {
raw.iter()
.map(|entry| {
let clip = entry
.get("clip")
.filter(|value| value.is_object())
.unwrap_or(entry);
Clip::from_json(clip)
})
.filter(|clip| !clip.id.is_empty())
.collect()
})
.unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
use std::time::Duration;
fn feed_body() -> String {
serde_json::json!({
"has_more": false,
"clips": [
{
"id": "a", "title": "Song A", "status": "complete",
"audio_url": "https://cdn1.suno.ai/a.mp3",
"metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
},
{"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
{"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
{
"id": "d", "title": "Context", "status": "complete",
"metadata": {"type": "rendered_context_window"}
}
]
})
.to_string()
}
#[test]
fn parse_feed_v3_filters_and_reads_pagination() {
let (clips, has_more, next_cursor) = parse_feed_v3(feed_body().as_bytes()).unwrap();
assert_eq!(has_more, Some(false));
assert_eq!(next_cursor, None);
assert_eq!(clips.len(), 1);
assert_eq!(clips[0].id, "a");
assert_eq!(clips[0].tags, "rock");
assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
}
#[test]
fn feed_v3_body_carries_filters_and_optional_cursor() {
let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
assert_eq!(first["filters"]["trashed"], "False");
assert!(first.get("cursor").is_none());
assert!(first["filters"].get("liked").is_none());
let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
assert_eq!(liked["filters"]["liked"], "True");
assert_eq!(liked["cursor"], "cur42");
}
#[test]
fn audiopipe_url_is_rewritten_to_cdn() {
let raw =
serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
assert_eq!(
Clip::from_json(&raw).audio_url,
"https://cdn1.suno.ai/x.mp3"
);
}
#[test]
fn list_clips_authenticates_then_reads_the_feed() {
let client_body = serde_json::json!({
"response": {
"last_active_session_id": "s",
"sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
}
})
.to_string();
let http = MockHttp::new(vec![
Rule::new(
"/v1/client/sessions/",
200,
r#"{"jwt": "a.b.c"}"#.to_string(),
),
Rule::new("/v1/client", 200, client_body),
Rule::new("/api/feed/v3", 200, feed_body()),
]);
let mut auth = ClerkAuth::new("eyJtoken");
pollster::block_on(auth.authenticate(&http)).unwrap();
let mut client = SunoClient::new(auth, RecordingClock::new());
let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert_eq!(clips.len(), 1);
assert_eq!(clips[0].id, "a");
assert!(complete);
}
#[test]
fn list_clips_reports_incomplete_when_paging_is_capped() {
let mut rules = auth_rules();
rules.push(Rule::new(
"/api/feed/v3",
200,
serde_json::json!({
"has_more": true,
"next_cursor": "cur1",
"clips": [{
"id": "a", "title": "Song A", "status": "complete",
"audio_url": "https://cdn1.suno.ai/a.mp3",
"metadata": {"type": "gen"}
}]
})
.to_string(),
));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(!complete);
}
fn auth_rules() -> Vec<Rule> {
let client_body = serde_json::json!({
"response": {
"last_active_session_id": "s",
"sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
}
})
.to_string();
vec![
Rule::new(
"/v1/client/sessions/",
200,
r#"{"jwt": "a.b.c"}"#.to_string(),
),
Rule::new("/v1/client", 200, client_body),
]
}
fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
let mut auth = ClerkAuth::new("eyJtoken");
pollster::block_on(auth.authenticate(http)).unwrap();
SunoClient::new(auth, RecordingClock::new())
}
fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
let mut auth = ClerkAuth::new("eyJtoken");
pollster::block_on(auth.authenticate(http)).unwrap();
SunoClient::new(auth, clock)
}
fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
let mut page = serde_json::json!({
"has_more": next_cursor.is_some(),
"clips": [{
"id": id, "title": "Song", "status": "complete",
"audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
"metadata": {"type": "gen"}
}]
});
if let Some(cursor) = next_cursor {
page["next_cursor"] = serde_json::json!(cursor);
}
page.to_string()
}
#[test]
fn list_clips_retries_a_rate_limited_page() {
let http = ScriptedHttp::new().with_auth().route_seq(
"/api/feed/v3",
vec![Reply::status(429), Reply::json(&feed_body())],
);
let clock = RecordingClock::new();
let mut client = scripted_client(&http, clock.clone());
let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert_eq!(clips.len(), 1);
assert!(complete);
assert_eq!(http.count("/api/feed/v3"), 2);
assert_eq!(clock.sleeps(), vec![Duration::from_secs(1)]);
}
#[test]
fn list_clips_honours_retry_after_on_a_throttled_page() {
let http = ScriptedHttp::new().with_auth().route_seq(
"/api/feed/v3",
vec![
Reply::status(429).with_retry_after(7),
Reply::json(&feed_body()),
],
);
let clock = RecordingClock::new();
let mut client = scripted_client(&http, clock.clone());
let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert_eq!(clips.len(), 1);
assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
}
#[test]
fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
let http = ScriptedHttp::new().with_auth().route_seq(
"/api/feed/v3",
vec![
Reply::json(&one_clip_page("a", Some("cur1"))),
Reply::status(429),
Reply::json(&one_clip_page("b", None)),
],
);
let clock = RecordingClock::new();
let mut client = scripted_client(&http, clock.clone());
let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(complete);
assert_eq!(clips.len(), 2);
let bodies = http.bodies();
let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
assert_eq!(retried["cursor"], "cur1");
assert_eq!(after_retry["cursor"], "cur1");
}
#[test]
fn list_clips_threads_the_cursor_across_pages() {
let http = ScriptedHttp::new().with_auth().route_seq(
"/api/feed/v3",
vec![
Reply::json(&one_clip_page("a", Some("cur1"))),
Reply::json(&one_clip_page("b", None)),
],
);
let clock = RecordingClock::new();
let mut client = scripted_client(&http, clock.clone());
let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(complete);
assert_eq!(clips.len(), 2);
let bodies = http.bodies();
let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
assert_eq!(feed_bodies.len(), 2);
let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
assert!(page1.get("cursor").is_none());
assert_eq!(page2["cursor"], "cur1");
}
#[test]
fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
let page = serde_json::json!({
"has_more": true,
"clips": [{
"id": "a", "title": "Song", "status": "complete",
"audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
}]
})
.to_string();
let http = ScriptedHttp::new()
.with_auth()
.route("/api/feed/v3", Reply::json(&page));
let clock = RecordingClock::new();
let mut client = scripted_client(&http, clock.clone());
let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(!complete);
assert_eq!(clips.len(), 1);
assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
}
#[test]
fn list_clips_is_incomplete_when_has_more_is_missing() {
let page = serde_json::json!({
"clips": [{
"id": "a", "title": "Song", "status": "complete",
"audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
}]
})
.to_string();
let http = ScriptedHttp::new()
.with_auth()
.route("/api/feed/v3", Reply::json(&page));
let clock = RecordingClock::new();
let mut client = scripted_client(&http, clock.clone());
let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(!complete);
assert_eq!(clips.len(), 1);
assert_eq!(http.count("/api/feed/v3"), 1);
}
#[test]
fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
let http = ScriptedHttp::new().with_auth().route_seq(
"/api/feed/v3",
vec![
Reply::json(&one_clip_page("a", Some("cur1"))),
Reply::status(500),
],
);
let clock = RecordingClock::new();
let mut client = scripted_client(&http, clock.clone());
let result = pollster::block_on(client.list_clips(&http, false, None));
assert!(matches!(result, Err(Error::Api(_))));
}
#[test]
fn list_clips_is_complete_on_an_empty_drained_feed() {
let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
let http = ScriptedHttp::new()
.with_auth()
.route("/api/feed/v3", Reply::json(&page));
let clock = RecordingClock::new();
let mut client = scripted_client(&http, clock.clone());
let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(complete);
assert!(clips.is_empty());
}
#[test]
fn list_clips_liked_scope_sends_the_liked_filter() {
let http = ScriptedHttp::new()
.with_auth()
.route("/api/feed/v3", Reply::json(&feed_body()));
let clock = RecordingClock::new();
let mut client = scripted_client(&http, clock.clone());
let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
let bodies = http.bodies();
let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
let value: Value = serde_json::from_str(feed_body).unwrap();
assert_eq!(value["filters"]["liked"], "True");
assert_eq!(value["filters"]["trashed"], "False");
}
#[test]
fn list_clips_paces_between_pages() {
let http = ScriptedHttp::new().with_auth().route_seq(
"/api/feed/v3",
vec![
Reply::json(&one_clip_page("a", Some("cur1"))),
Reply::json(&one_clip_page("e", None)),
],
);
let clock = RecordingClock::new();
let mut client = scripted_client(&http, clock.clone());
let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
assert!(complete);
assert_eq!(clips.len(), 2);
assert_eq!(http.count("/api/feed/v3"), 2);
assert_eq!(clock.sleeps(), vec![crate::consts::FEED_PAGE_DELAY]);
}
#[test]
fn list_clips_gives_up_after_max_retries() {
let http = ScriptedHttp::new()
.with_auth()
.route("/api/feed/v3", Reply::status(429));
let clock = RecordingClock::new();
let mut client = scripted_client(&http, clock.clone());
let result = pollster::block_on(client.list_clips(&http, false, None));
assert!(matches!(result, Err(Error::RateLimited { .. })));
let budget = crate::consts::API_MAX_RETRIES as usize;
assert_eq!(clock.sleeps().len(), budget);
assert_eq!(http.count("/api/feed/v3"), budget + 1);
}
#[test]
fn parse_clip_accepts_bare_and_wrapped_shapes() {
let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
let missing = serde_json::json!({"detail": "not found"}).to_string();
assert!(parse_clip(missing.as_bytes()).is_none());
}
#[test]
fn get_clip_uses_the_dedicated_endpoint() {
let clip_body = serde_json::json!({
"id": "z", "title": "Zed", "status": "complete",
"audio_url": "https://cdn1.suno.ai/z.mp3",
"metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
})
.to_string();
let mut rules = auth_rules();
rules.push(Rule::new("/api/clip/", 200, clip_body));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
assert_eq!(clip.id, "z");
assert_eq!(clip.title, "Zed");
assert_eq!(clip.tags, "jazz");
}
#[test]
fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
let mut rules = auth_rules();
rules.push(Rule::new(
"/api/clip/",
404,
r#"{"detail": "not found"}"#.to_string(),
));
rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
assert_eq!(clip.id, "a");
assert_eq!(clip.tags, "rock");
}
#[test]
fn request_wav_accepts_a_2xx_status() {
let mut rules = auth_rules();
rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
}
#[test]
fn wav_url_reads_the_ready_url() {
let mut rules = auth_rules();
rules.push(Rule::new(
"/wav_file/",
200,
r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
}
#[test]
fn wav_url_is_none_until_the_render_is_ready() {
let mut rules = auth_rules();
rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
assert_eq!(url, None);
}
#[test]
fn get_clips_by_ids_fetches_each_id_and_keeps_artefacts() {
let p1 = serde_json::json!({
"id": "p1", "title": "Infill Ancestor", "status": "complete",
"metadata": {"type": "gen", "task": "infill"}
})
.to_string();
let p2 = serde_json::json!({
"id": "p2", "title": "Uploaded Root", "status": "complete",
"metadata": {"type": "upload"}
})
.to_string();
let mut rules = auth_rules();
rules.push(Rule::new("/api/clip/p1", 200, p1));
rules.push(Rule::new("/api/clip/p2", 200, p2));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"])).unwrap();
assert_eq!(
clips.len(),
2,
"infill and upload ancestors must not be filtered"
);
assert_eq!(clips[0].id, "p1");
assert_eq!(clips[1].id, "p2");
}
#[test]
fn get_clips_by_ids_returns_a_trashed_clip() {
let trashed = serde_json::json!({
"id": "t1", "title": "Trashed Ancestor", "status": "complete",
"is_trashed": true, "metadata": {"type": "gen"}
})
.to_string();
let mut rules = auth_rules();
rules.push(Rule::new("/api/clip/t1", 200, trashed));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"])).unwrap();
assert_eq!(clips.len(), 1);
assert_eq!(clips[0].id, "t1");
assert!(clips[0].is_trashed);
}
#[test]
fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
let only = serde_json::json!({
"id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
})
.to_string();
let http = ScriptedHttp::new()
.with_auth()
.route("/api/clip/gone", Reply::status(404))
.route("/api/clip/only", Reply::json(&only));
let mut client = scripted_client(&http, RecordingClock::new());
let clips =
pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"])).unwrap();
assert_eq!(clips.len(), 1, "the 404 id is skipped");
assert_eq!(clips[0].id, "only");
assert_eq!(http.count("/api/clip/only"), 1);
assert_eq!(http.count("/api/clip/gone"), 1);
}
#[test]
fn get_clip_parent_reads_the_parent_clip() {
let parent = serde_json::json!({
"id": "par", "title": "Ancestor", "status": "complete",
"metadata": {"type": "gen"}
})
.to_string();
let mut rules = auth_rules();
rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
assert_eq!(clip.unwrap().id, "par");
}
#[test]
fn get_clip_parent_is_none_for_a_root() {
let mut rules = auth_rules();
rules.push(Rule::new(
"/api/clips/parent",
404,
r#"{"detail": "no parent"}"#.to_string(),
));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
assert!(clip.is_none());
}
#[test]
fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
for status in [500u16, 503] {
let mut rules = auth_rules();
rules.push(Rule::new(
"/api/clips/parent",
status,
r#"{"detail": "server error"}"#.to_string(),
));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let result = pollster::block_on(client.get_clip_parent(&http, "child"));
assert!(
matches!(result, Err(Error::Api(_))),
"status {status} must propagate as an error, not Ok(None)"
);
}
}
#[test]
fn get_playlists_maps_entries_and_skips_missing_ids() {
let page1 = serde_json::json!({
"playlists": [
{"id": "pl1", "name": "Road Trip", "num_total_results": 12},
{"id": "", "name": "No Id", "num_total_results": 3},
{"name": "Also No Id"}
]
})
.to_string();
let mut rules = auth_rules();
rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
rules.push(Rule::new(
"/api/playlist/me?page=2",
200,
r#"{"playlists": []}"#.to_string(),
));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
assert_eq!(playlists.len(), 1, "entries without an id are dropped");
assert_eq!(
playlists[0],
Playlist {
id: "pl1".to_owned(),
name: "Road Trip".to_owned(),
num_clips: 12,
}
);
}
#[test]
fn get_playlists_defaults_a_missing_name_to_untitled() {
let page1 = serde_json::json!({
"playlists": [{"id": "pl9", "num_total_results": 1}]
})
.to_string();
let mut rules = auth_rules();
rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
rules.push(Rule::new(
"/api/playlist/me?page=2",
200,
r#"{"playlists": []}"#.to_string(),
));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
assert_eq!(playlists[0].name, "Untitled");
}
#[test]
fn get_playlist_clips_preserves_order_and_unwraps_clip() {
let body = serde_json::json!({
"playlist_clips": [
{"clip": {
"id": "second", "title": "Second", "status": "complete",
"metadata": {"duration": 60.0, "type": "gen"}
}},
{"clip": {
"id": "first", "title": "First", "status": "complete",
"metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
}}
]
})
.to_string();
let mut rules = auth_rules();
rules.push(Rule::new("/api/playlist/pl1/", 200, body));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let clips = pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
assert_eq!(clips.len(), 2, "an infill member is not filtered out");
assert_eq!(clips[0].id, "second");
assert_eq!(clips[1].id, "first");
}
#[test]
fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
let mut rules = auth_rules();
rules.push(Rule::new(
"/api/playlist/empty/",
200,
r#"{"playlist_clips": []}"#.to_string(),
));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let clips = pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
assert!(clips.is_empty());
}
}