suno_core/client.rs
1//! The Suno API client: lists the library behind the [`Http`](crate::Http) port.
2
3use std::collections::{BTreeSet, HashMap};
4use std::sync::Mutex;
5use std::time::Instant;
6
7use futures_util::stream::{self, StreamExt};
8
9use crate::auth::ClerkAuth;
10use crate::backoff::{backoff_delay, retry_after};
11use crate::clock::Clock;
12use crate::consts::{
13 API_MAX_RETRIES, BILLING_INFO_PATH, CLIP_PARENT_PATH, FEED_INITIAL_RATE, FEED_V3_PATH,
14 GET_SONGS_BY_IDS_PATH, GET_SONGS_CHUNK, MAX_PAGES, PLAYLIST_ME_PATH, PLAYLIST_PATH,
15 SUNO_API_BASE_URL,
16};
17use crate::error::{Error, Result};
18use crate::http::{Http, HttpRequest, Method};
19use crate::limiter::{AdaptiveLimiter, retry_after_delay};
20use crate::lyrics::AlignedLyrics;
21use crate::model::{BillingInfo, Clip, Playlist, Stem};
22use crate::wire::{
23 feed_v3_body, parse_billing_info, parse_clip, parse_feed_v3, parse_playlist_clips,
24 parse_playlists, parse_songs_batch, parse_stem_page_count, parse_stems_page, parse_wav_url,
25};
26
27/// A client for the Suno library API, owning the account's [`ClerkAuth`].
28///
29/// Holds the [`Clock`] so [`api_request`](Self::api_request) can back off on a
30/// `429` or transient failure, and an [`AdaptiveLimiter`] that paces reactively:
31/// it waits nowhere until a `429`, then halves the rate and ramps it back on
32/// sustained success.
33pub struct SunoClient<C> {
34 auth: ClerkAuth,
35 clock: C,
36 limiter: Mutex<AdaptiveLimiter>,
37}
38
39impl<C: Clock> SunoClient<C> {
40 /// Create a client from a fresh or already-authenticated [`ClerkAuth`].
41 pub fn new(auth: ClerkAuth, clock: C) -> Self {
42 Self {
43 auth,
44 clock,
45 limiter: Mutex::new(AdaptiveLimiter::new(FEED_INITIAL_RATE)),
46 }
47 }
48
49 /// Borrow the underlying authenticator.
50 pub fn auth(&self) -> &ClerkAuth {
51 &self.auth
52 }
53
54 /// The adaptive limiter's current requests-per-second rate, for tests.
55 #[cfg(test)]
56 pub(crate) fn limiter_rate(&self) -> f64 {
57 self.limiter.lock().unwrap().rate()
58 }
59
60 /// List clips across the whole library, or only liked clips.
61 ///
62 /// Walks the cursor-paginated `POST /api/feed/v3` feed, hard-capped at
63 /// [`MAX_PAGES`], truncating to `limit` when set.
64 ///
65 /// The `complete` flag is `true` only when paging ended on a server-reported
66 /// `has_more == false`; any other stop (missing `has_more`, no usable cursor,
67 /// `limit`, [`MAX_PAGES`], transport error) yields `false` so the caller
68 /// never treats a truncated listing as authoritative for deletion.
69 /// `any_filtered` is `true` when the downloadable filter dropped any clip,
70 /// which likewise denies deletion authority since it may have hidden a
71 /// manifest-tracked clip.
72 pub async fn list_clips(
73 &self,
74 http: &impl Http,
75 liked: bool,
76 limit: Option<usize>,
77 ) -> Result<(Vec<Clip>, bool, bool)> {
78 let mut clips = Vec::new();
79 let mut cursor: Option<String> = None;
80 let mut complete = false;
81 let mut any_filtered = false;
82 for _ in 0..MAX_PAGES {
83 let body = feed_v3_body(liked, cursor.as_deref());
84 let response = self
85 .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
86 .await?;
87 let page = parse_feed_v3(&response)?;
88 clips.extend(page.clips);
89 any_filtered |= page.any_filtered;
90 match page.has_more {
91 Some(false) => {
92 complete = true;
93 break;
94 }
95 Some(true) => match page.next_cursor {
96 Some(next) => cursor = Some(next),
97 None => break,
98 },
99 None => break,
100 }
101 if limit.is_some_and(|n| clips.len() >= n) {
102 break;
103 }
104 }
105 if let Some(n) = limit {
106 clips.truncate(n);
107 }
108 Ok((clips, complete, any_filtered))
109 }
110
111 /// Fetch one clip by ID.
112 ///
113 /// Tries the dedicated `/api/clip/{id}` endpoint first, then falls back to
114 /// scanning the library feed if that endpoint yields no matching clip.
115 pub async fn get_clip(&self, http: &impl Http, id: &str) -> Result<Clip> {
116 if let Some(clip) = self.try_get_clip(http, id).await? {
117 return Ok(clip);
118 }
119 self.find_in_feed(http, id).await
120 }
121
122 /// Ask Suno to render a clip to lossless WAV (server-side, asynchronous).
123 pub async fn request_wav(&self, http: &impl Http, id: &str) -> Result<()> {
124 let path = format!("/api/gen/{id}/convert_wav/");
125 self.api_request(http, Method::Post, &path, Vec::new())
126 .await?;
127 Ok(())
128 }
129
130 /// Read the rendered WAV URL for a clip, or `None` while it is not ready.
131 ///
132 /// A `404` maps to `None` (not yet rendered), and it skips the shared retry
133 /// so the caller's poll loop owns that budget.
134 pub async fn wav_url(&self, http: &impl Http, id: &str) -> Result<Option<String>> {
135 let path = format!("/api/gen/{id}/wav_file/");
136 let body = match self.api_get(http, &path).await {
137 Ok(body) => body,
138 Err(Error::NotFound(_)) => return Ok(None),
139 Err(err) => return Err(err),
140 };
141 parse_wav_url(&body)
142 }
143
144 /// Fetch a clip's word- and line-level aligned (synced) lyrics.
145 ///
146 /// The trailing slash on `.../aligned_lyrics/v2/` is required. An
147 /// instrumental or un-alignable clip returns `200` with empty arrays, and a
148 /// `404` is treated the same way, so an absent alignment is "no synced
149 /// lyrics" rather than a run failure.
150 pub async fn aligned_lyrics(&self, http: &impl Http, id: &str) -> Result<AlignedLyrics> {
151 let path = format!("/api/gen/{id}/aligned_lyrics/v2/");
152 match self.api_get_retrying(http, &path).await {
153 Ok(body) => Ok(AlignedLyrics::from_bytes(&body)),
154 Err(Error::NotFound(_)) => Ok(AlignedLyrics::default()),
155 Err(err) => Err(err),
156 }
157 }
158
159 /// Fetch specific clips by id, batch-first with a per-id fallback.
160 ///
161 /// Used by lineage resolution to gap-fill ancestors absent from a normal
162 /// listing, including trashed ones. Ids are fetched in one batch via
163 /// [`get_songs_by_ids`](Self::get_songs_by_ids); any the batch omits fall
164 /// back to one `GET /api/clip/{id}` each, bounded by `concurrency`, attempted
165 /// once, with a `404` skipped. A `429` while batching propagates rather than
166 /// fanning out into per-id requests.
167 ///
168 /// No downloadability filter is applied: an ancestor may be an infill or
169 /// context artefact the walk must still traverse, so these clips are for
170 /// resolution only and must never be treated as download candidates. Ids are
171 /// deduplicated in order and the result preserves that order, matched by id.
172 pub async fn get_clips_by_ids(
173 &self,
174 http: &impl Http,
175 ids: &[&str],
176 concurrency: usize,
177 ) -> Result<Vec<Clip>> {
178 let ordered = dedup_nonempty(ids);
179 let mut found: HashMap<&str, Clip> = self
180 .get_songs_by_ids(http, &ordered)
181 .await?
182 .into_iter()
183 .filter_map(|clip| {
184 ordered
185 .iter()
186 .find(|id| **id == clip.id)
187 .map(|id| (*id, clip))
188 })
189 .collect();
190 let omitted: Vec<&str> = ordered
191 .iter()
192 .copied()
193 .filter(|id| !found.contains_key(id))
194 .collect();
195 if !omitted.is_empty() {
196 for clip in self
197 .fetch_clips_individually(http, &omitted, concurrency)
198 .await?
199 {
200 if let Some(id) = ordered.iter().copied().find(|id| *id == clip.id) {
201 found.insert(id, clip);
202 }
203 }
204 }
205 Ok(ordered.iter().filter_map(|id| found.remove(id)).collect())
206 }
207
208 /// Batch-fetch clips by id via `GET /api/clips/get_songs_by_ids?ids=…&ids=…`.
209 ///
210 /// The pure batch primitive: deduplicated ids are split into
211 /// [`GET_SONGS_CHUNK`] chunks and matched back by id, preserving input order.
212 /// Ids the batch does not return are left for the caller to fill.
213 ///
214 /// The endpoint is undocumented and may be unavailable: a chunk it cannot
215 /// serve (any `4xx`/`5xx`, transport failure, or unexpected body) yields
216 /// nothing for that chunk rather than erroring, so an outage degrades rather
217 /// than breaks. A `429` rides the retry and then propagates rather than
218 /// fanning out into per-id requests; an auth failure likewise propagates.
219 pub async fn get_songs_by_ids(&self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
220 let ordered = dedup_nonempty(ids);
221 let mut found: HashMap<&str, Clip> = HashMap::new();
222 for chunk in ordered.chunks(GET_SONGS_CHUNK) {
223 let query = chunk
224 .iter()
225 .map(|id| format!("ids={id}"))
226 .collect::<Vec<_>>()
227 .join("&");
228 let path = format!("{GET_SONGS_BY_IDS_PATH}?{query}");
229 let clips = match self.api_get_retrying(http, &path).await {
230 Ok(body) => parse_songs_batch(&body).unwrap_or_default(),
231 Err(err @ (Error::RateLimited { .. } | Error::Auth(_))) => return Err(err),
232 Err(_) => Vec::new(),
233 };
234 for clip in clips {
235 if let Some(id) = chunk.iter().copied().find(|id| *id == clip.id) {
236 found.insert(id, clip);
237 }
238 }
239 }
240 Ok(ordered.iter().filter_map(|id| found.remove(id)).collect())
241 }
242
243 /// The per-id `GET /api/clip/{id}` fallback for
244 /// [`get_clips_by_ids`](Self::get_clips_by_ids), with bounded concurrency.
245 /// Returns any clip (trashed or artefact) unfiltered; a `404` is skipped and
246 /// input order is preserved.
247 async fn fetch_clips_individually(
248 &self,
249 http: &impl Http,
250 ids: &[&str],
251 concurrency: usize,
252 ) -> Result<Vec<Clip>> {
253 let limit = concurrency.max(1);
254 let fetched = stream::iter(ids.iter().copied())
255 .map(|id| async move {
256 let path = format!("/api/clip/{id}");
257 match self.api_get_retrying(http, &path).await {
258 Ok(body) => Ok(parse_clip(&body)),
259 Err(Error::NotFound(_)) => Ok(None),
260 Err(err) => Err(err),
261 }
262 })
263 .buffered(limit)
264 .collect::<Vec<_>>()
265 .await;
266 let mut clips = Vec::new();
267 for item in fetched {
268 if let Some(clip) = item? {
269 clips.push(clip);
270 }
271 }
272 Ok(clips)
273 }
274
275 /// Fetch a clip's immediate parent, or `None` when the clip is a root.
276 ///
277 /// A root's parent comes back as `200` with a bodiless, id-less clip (not a
278 /// `404`); [`parse_clip`] gates on a non-empty id so that maps to `Ok(None)`.
279 /// The `404` arm is a fallback for the alternative encoding. Any other
280 /// failure propagates rather than being mistaken for a root.
281 pub async fn get_clip_parent(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
282 let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
283 match self.api_get_retrying(http, &path).await {
284 Ok(body) => Ok(parse_clip(&body)),
285 Err(Error::NotFound(_)) => Ok(None),
286 Err(err) => Err(err),
287 }
288 }
289
290 /// List the account's own playlists, paging `/api/playlist/me`.
291 ///
292 /// Trashed and share-list playlists are excluded by query. Paging stops on
293 /// the first empty page, is hard-capped at [`MAX_PAGES`], and de-duplicates
294 /// by id so a server that ignores the page parameter cannot loop or inflate
295 /// the set.
296 ///
297 /// A hard failure propagates: the caller then refuses every playlist
298 /// deletion this run, so a dropped fetch can never remove a `.m3u8`.
299 pub async fn get_playlists(&self, http: &impl Http) -> Result<Vec<Playlist>> {
300 let mut playlists = Vec::new();
301 let mut seen = BTreeSet::new();
302 for page in 1..=MAX_PAGES {
303 let path =
304 format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
305 let body = self.api_get_retrying(http, &path).await?;
306 let page_playlists = parse_playlists(&body)?;
307 if page_playlists.is_empty() {
308 break;
309 }
310 for playlist in page_playlists {
311 if seen.insert(playlist.id.clone()) {
312 playlists.push(playlist);
313 }
314 }
315 }
316 Ok(playlists)
317 }
318
319 /// Fetch one playlist's clips in Suno order via `/api/playlist/{id}/`.
320 ///
321 /// `playlist_clips[]` is already ordered with trashed members excluded, so
322 /// order is preserved and no downloadability filter is applied. Only clips
323 /// with a non-empty id are kept.
324 ///
325 /// The returned `bool` is a completeness signal for deletion authority:
326 /// `true` only when `num_total_results` is present, equals the raw count, and
327 /// no member was dropped for a missing id. A short or id-missing page returns
328 /// `false`, so a Mirror playlist under `library = "off"` is never
329 /// authoritative unless its whole member set was seen.
330 pub async fn get_playlist_clips(
331 &self,
332 http: &impl Http,
333 id: &str,
334 ) -> Result<(Vec<Clip>, bool)> {
335 let path = format!("{PLAYLIST_PATH}{id}/");
336 let body = self.api_get_retrying(http, &path).await?;
337 parse_playlist_clips(&body)
338 }
339
340 /// Read the authenticated account's billing information.
341 pub async fn get_billing_info(&self, http: &impl Http) -> Result<BillingInfo> {
342 let body = self.api_get_retrying(http, BILLING_INFO_PATH).await?;
343 parse_billing_info(&body)
344 }
345
346 /// List a clip's already-separated stems (free, read-only).
347 ///
348 /// Pages `GET /api/clip/{id}/stems/pages` then `.../stems?page=P` (0-indexed).
349 /// This endpoint only reads: it never spends credits or triggers separation,
350 /// so it is safe on the bulk mirror path. Call only when the clip's
351 /// `has_stem` is true.
352 ///
353 /// The `complete` flag is `true` only when the page count came back and every
354 /// page drained after at least one stem was seen. An empty listing
355 /// (`pages == 0` or a `400`/`404` on the page-count endpoint), a transport
356 /// failure, a partial drain, or a count above [`MAX_PAGES`] all yield
357 /// `false`, so the caller KEEPS existing local stems rather than reading the
358 /// absence as "no stems".
359 pub async fn list_stems(&self, http: &impl Http, clip_id: &str) -> Result<(Vec<Stem>, bool)> {
360 let declared = self.stem_page_count(http, clip_id).await?;
361 // Zero pages is Suno's "no stems" answer: indeterminate, never an
362 // authoritative empty.
363 if declared == 0 {
364 return Ok((Vec::new(), false));
365 }
366 let pages = declared.min(MAX_PAGES);
367 let mut stems: Vec<Stem> = Vec::new();
368 for page in 0..pages {
369 // Pages are 0-indexed; no trailing slash before the query (unlike
370 // `.../stems/pages`).
371 let path = format!("/api/clip/{clip_id}/stems?page={page}");
372 // A page error mid-enumeration is indeterminate: surface it so the
373 // caller keeps existing stems rather than reading a partial drain as
374 // authoritative.
375 let body = self.api_get_retrying(http, &path).await?;
376 stems.extend(parse_stems_page(&body));
377 }
378 dedupe_stems(&mut stems);
379 let complete = !stems.is_empty() && declared <= MAX_PAGES;
380 Ok((stems, complete))
381 }
382
383 /// Read the stems page count from `GET /api/clip/{id}/stems/pages`.
384 ///
385 /// A clip with no stems answers `400`/`404`; both map to `0` (indeterminate,
386 /// never an authoritative empty). Any other error propagates so the caller
387 /// keeps the stems as unknown.
388 async fn stem_page_count(&self, http: &impl Http, clip_id: &str) -> Result<u32> {
389 let path = format!("/api/clip/{clip_id}/stems/pages");
390 match self.api_get_retrying(http, &path).await {
391 Ok(body) => Ok(parse_stem_page_count(&body)),
392 Err(err) if is_invalid_page_error(&err) => Ok(0),
393 Err(Error::NotFound(_)) => Ok(0),
394 Err(err) => Err(err),
395 }
396 }
397
398 /// Try the dedicated clip endpoint, returning `None` when it is missing or
399 /// returns a body that does not yield the requested clip.
400 async fn try_get_clip(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
401 let path = format!("/api/clip/{id}");
402 match self.api_get_retrying(http, &path).await {
403 Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
404 Err(Error::NotFound(_)) => Ok(None),
405 Err(err) => Err(err),
406 }
407 }
408
409 /// Locate a clip by scanning the library feed.
410 async fn find_in_feed(&self, http: &impl Http, id: &str) -> Result<Clip> {
411 let (clips, _complete, _) = self.list_clips(http, false, None).await?;
412 clips
413 .into_iter()
414 .find(|clip| clip.id == id)
415 .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
416 }
417
418 /// Perform an authenticated GET, refreshing the JWT once on a 401/403.
419 async fn api_get(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
420 self.api_request(http, Method::Get, path, Vec::new()).await
421 }
422
423 /// A retrying GET: [`api_send_retrying`](Self::api_send_retrying) with no body.
424 async fn api_get_retrying(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
425 self.api_send_retrying(http, Method::Get, path, Vec::new())
426 .await
427 }
428
429 /// Like [`api_request`](Self::api_request) but paces through the adaptive
430 /// rate limiter and backs off via the [`Clock`] on a `429` or transient
431 /// failure, up to [`API_MAX_RETRIES`] times. Each attempt reconstructs the
432 /// request, so a throttled feed page re-POSTs the same cursor rather than
433 /// skipping ahead.
434 ///
435 /// Pacing lives at this single per-request layer so it composes with any
436 /// paged walk. The WAV render flow instead uses the plain
437 /// [`api_get`](Self::api_get) so the executor owns that retry budget.
438 async fn api_send_retrying(
439 &self,
440 http: &impl Http,
441 method: Method,
442 path: &str,
443 body: Vec<u8>,
444 ) -> Result<Vec<u8>> {
445 let pace = self.limiter.lock().unwrap().pace(Instant::now());
446 if !pace.is_zero() {
447 self.clock.sleep(pace).await;
448 }
449 let mut retries = 0;
450 loop {
451 match self.api_request(http, method, path, body.clone()).await {
452 Ok(response) => return Ok(response),
453 Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
454 self.clock.sleep(retry_after_delay(retry_after)).await;
455 retries += 1;
456 }
457 Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
458 self.clock.sleep(backoff_delay(retries, None)).await;
459 retries += 1;
460 }
461 Err(err) => return Err(err),
462 }
463 }
464 }
465
466 /// Perform an authenticated request, refreshing the JWT once on a 401/403.
467 ///
468 /// `body` is sent only by the adapter when non-empty, so a GET or a bodyless
469 /// POST reaches the network unchanged.
470 async fn api_request(
471 &self,
472 http: &impl Http,
473 method: Method,
474 path: &str,
475 body: Vec<u8>,
476 ) -> Result<Vec<u8>> {
477 // Crate-wide POST allow-list: every mutating request funnels through
478 // here, so a destructive or credit-spending endpoint can never be sent.
479 // GETs are free and unrestricted.
480 if method == Method::Post && !post_path_allowed(path) {
481 return Err(Error::Refused(format!(
482 "POST to {path} is not on the allow-list"
483 )));
484 }
485 let url = format!("{SUNO_API_BASE_URL}{path}");
486 let mut auth_refreshed = false;
487 loop {
488 let jwt = self.auth.ensure_jwt(self.clock.now_unix(), http).await?;
489 let mut request = match method {
490 Method::Get => HttpRequest::get(url.clone()),
491 Method::Post => HttpRequest::post(url.clone(), body.clone()),
492 };
493 request
494 .headers
495 .push(("Authorization".to_string(), format!("Bearer {jwt}")));
496 let response = http
497 .send(request)
498 .await
499 .map_err(|err| Error::Connection(err.to_string()))?;
500 match response.status {
501 200..=299 => {
502 self.limiter.lock().unwrap().on_success();
503 return Ok(response.body);
504 }
505 401 | 403 if !auth_refreshed => {
506 self.auth.invalidate_jwt();
507 auth_refreshed = true;
508 }
509 401 | 403 => {
510 return Err(Error::Auth(format!(
511 "Suno API auth failed with status {}",
512 response.status
513 )));
514 }
515 429 => {
516 self.limiter.lock().unwrap().on_rate_limit();
517 return Err(Error::RateLimited {
518 retry_after: retry_after(&response),
519 });
520 }
521 400 => {
522 let preview: String = String::from_utf8_lossy(&response.body)
523 .chars()
524 .take(200)
525 .collect();
526 return Err(Error::BadRequest(format!(
527 "Suno API returned 400: {preview}"
528 )));
529 }
530 404 => {
531 return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
532 }
533 status => {
534 let preview: String = String::from_utf8_lossy(&response.body)
535 .chars()
536 .take(200)
537 .collect();
538 return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
539 }
540 }
541 }
542 }
543}
544
545/// Whether a Suno API path may be the target of a POST (the crate-wide POST
546/// allow-list), deliberately narrow so a mutating request only ever reaches a
547/// vetted endpoint: the library feed ([`FEED_V3_PATH`]) and the per-clip WAV
548/// render (`…/convert_wav/`). Any credit-spending endpoint is deliberately
549/// absent; GETs are never gated.
550fn post_path_allowed(path: &str) -> bool {
551 if path == FEED_V3_PATH {
552 return true;
553 }
554 if let Some(rest) = path.strip_prefix("/api/gen/")
555 && let Some(id) = rest.strip_suffix("/convert_wav/")
556 {
557 return is_single_id_segment(id);
558 }
559 false
560}
561
562/// Whether `segment` is a single, non-empty path id segment: no slash, no query,
563/// and no `..` traversal, so an allow-list match can never be smuggled past by a
564/// crafted path.
565fn is_single_id_segment(segment: &str) -> bool {
566 !segment.is_empty()
567 && !segment.contains('/')
568 && !segment.contains('?')
569 && !segment.contains("..")
570}
571
572/// Whether an error is Suno's "no stems" answer (a `400`) on the page-count
573/// endpoint, distinguished from a transient `5xx` so a server error is never
574/// mistaken for "no stems".
575fn is_invalid_page_error(err: &Error) -> bool {
576 matches!(err, Error::BadRequest(_))
577}
578
579/// Drop stems that repeat across pages, keeping the first occurrence of each
580/// download URL so a paged listing counts a stem once.
581fn dedupe_stems(stems: &mut Vec<Stem>) {
582 let mut seen = BTreeSet::new();
583 stems.retain(|stem| seen.insert(stem.url.clone()));
584}
585
586/// Deduplicate ids in first-seen order, dropping empties. Shared by the by-id
587/// fetch paths so the batch, the fallback, and the returned order all agree.
588fn dedup_nonempty<'a>(ids: &[&'a str]) -> Vec<&'a str> {
589 let mut seen: BTreeSet<&str> = BTreeSet::new();
590 ids.iter()
591 .copied()
592 .filter(|id| !id.is_empty() && seen.insert(id))
593 .collect()
594}
595
596#[cfg(test)]
597mod tests;