use serde_json::Value;
use crate::auth::ClerkAuth;
use crate::consts::{
CLIP_PARENT_PATH, FEED_V2_PATH, IDS_PER_REQUEST, MAX_PAGES, PLAYLIST_ME_PATH, PLAYLIST_PATH,
SUNO_API_BASE_URL,
};
use crate::error::{Error, Result};
use crate::http::{Http, HttpRequest, Method};
use crate::model::Clip;
const EXCLUDED_TASKS: [&str; 2] = ["infill", "fixed_infill"];
const EXCLUDED_TYPES: [&str; 1] = ["rendered_context_window"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Playlist {
pub id: String,
pub name: String,
pub num_clips: u64,
}
pub struct SunoClient {
auth: ClerkAuth,
}
impl SunoClient {
pub fn new(auth: ClerkAuth) -> Self {
Self { auth }
}
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 suffix = if liked { "&is_liked=true" } else { "" };
let mut complete = false;
for page in 0..MAX_PAGES {
let path = format!("/api/feed/v2/?page={page}{suffix}");
let body = self.api_get(http, &path).await?;
let (page_clips, has_more) = parse_feed(&body)?;
clips.extend(page_clips);
if !has_more {
complete = true;
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).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();
for chunk in ids.chunks(IDS_PER_REQUEST) {
if chunk.is_empty() {
continue;
}
let joined = chunk.join(",");
let path = format!("{FEED_V2_PATH}?ids={joined}");
let body = self.api_get(http, &path).await?;
clips.extend(map_all_clips(&body)?);
}
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(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 {
let path =
format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
let body = self.api_get(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(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(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).await
}
async fn api_request(
&mut self,
http: &impl Http,
method: Method,
path: &str,
) -> Result<Vec<u8>> {
let url = format!("{SUNO_API_BASE_URL}{path}");
for attempt in 0..2 {
let jwt = self.auth.ensure_jwt(http).await?;
let request = HttpRequest {
method,
url: url.clone(),
headers: vec![("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 attempt == 0 => self.auth.invalidate_jwt(),
401 | 403 => {
return Err(Error::Auth(format!(
"Suno API auth failed with status {}",
response.status
)));
}
429 => return Err(Error::RateLimited),
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}")));
}
}
}
Err(Error::Api("Suno API request failed after retries".into()))
}
}
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 parse_feed(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
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(), false));
};
let clips = object
.get("clips")
.and_then(Value::as_array)
.map(|raw| {
raw.iter()
.filter(|clip| keep_clip(clip))
.map(Clip::from_json)
.collect()
})
.unwrap_or_default();
let has_more = object
.get("has_more")
.and_then(Value::as_bool)
.unwrap_or(false);
Ok((clips, has_more))
}
fn map_all_clips(body: &[u8]) -> Result<Vec<Clip>> {
let data: Value = serde_json::from_slice(body)
.map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
let items: &[Value] = match &data {
Value::Array(items) => items.as_slice(),
Value::Object(_) => data
.get("clips")
.and_then(Value::as_array)
.map_or(&[][..], |arr| arr.as_slice()),
_ => &[],
};
Ok(items
.iter()
.map(Clip::from_json)
.filter(|clip| !clip.id.is_empty())
.collect())
}
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())
}
fn keep_clip(raw: &Value) -> bool {
if raw.get("status").and_then(Value::as_str) != Some("complete") {
return false;
}
let metadata = raw.get("metadata");
let clip_type = metadata.and_then(|m| m.get("type")).and_then(Value::as_str);
if clip_type.is_some_and(|t| EXCLUDED_TYPES.contains(&t)) {
return false;
}
let task = metadata.and_then(|m| m.get("task")).and_then(Value::as_str);
!task.is_some_and(|t| EXCLUDED_TASKS.contains(&t))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{MockHttp, Rule};
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_filters_and_maps() {
let (clips, has_more) = parse_feed(feed_body().as_bytes()).unwrap();
assert!(!has_more);
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 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/v2", 200, feed_body()),
]);
let mut auth = ClerkAuth::new("eyJtoken");
pollster::block_on(auth.authenticate(&http)).unwrap();
let mut client = SunoClient::new(auth);
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/v2",
200,
serde_json::json!({
"has_more": true,
"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 {
let mut auth = ClerkAuth::new("eyJtoken");
pollster::block_on(auth.authenticate(http)).unwrap();
SunoClient::new(auth)
}
#[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/v2", 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_uses_the_ids_filter_and_keeps_all_clips() {
let feed = serde_json::json!({
"clips": [
{
"id": "p1", "title": "Infill Ancestor", "status": "complete",
"metadata": {"type": "gen", "task": "infill"}
},
{
"id": "p2", "title": "Uploaded Root", "status": "complete",
"metadata": {"type": "upload"}
}
]
})
.to_string();
let mut rules = auth_rules();
rules.push(Rule::new("/api/feed/v2/?ids=p1,p2", 200, feed));
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_accepts_a_bare_array_body() {
let body = serde_json::json!([
{"id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}}
])
.to_string();
let mut rules = auth_rules();
rules.push(Rule::new("/api/feed/v2/?ids=only", 200, body));
let http = MockHttp::new(rules);
let mut client = authed_client(&http);
let clips = pollster::block_on(client.get_clips_by_ids(&http, &["only"])).unwrap();
assert_eq!(clips.len(), 1);
assert_eq!(clips[0].id, "only");
}
#[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());
}
}