pub struct SunoClient<C> { /* private fields */ }Expand description
A client for the Suno library API, owning the account’s ClerkAuth.
The Clock is held so api_request can back off
through the port on a 429 or transient failure — the engine still sleeps
nowhere itself. The [AdaptiveLimiter] paces reactively: an unthrottled
listing waits nowhere, and only after a 429 does it space requests out,
halving the rate and ramping it back after a run of clean successes so pacing
tracks Suno’s real limit rather than a fixed constant.
Implementations§
Source§impl<C: Clock> SunoClient<C>
impl<C: Clock> SunoClient<C>
Sourcepub fn new(auth: ClerkAuth, clock: C) -> Self
pub fn new(auth: ClerkAuth, clock: C) -> Self
Create a client from a fresh or already-authenticated ClerkAuth.
Sourcepub async fn list_clips(
&self,
http: &impl Http,
liked: bool,
limit: Option<usize>,
) -> Result<(Vec<Clip>, bool)>
pub async fn list_clips( &self, http: &impl Http, liked: bool, limit: Option<usize>, ) -> Result<(Vec<Clip>, bool)>
List clips across the whole library, or only liked clips.
Walks the cursor-paginated POST /api/feed/v3 feed, following
next_cursor until the server reports the end. Once limit clips have
been collected it stops at the next page boundary and truncates to
limit. Paging is hard-capped at [MAX_PAGES] so a runaway
has_more can never loop forever. When liked is set the feed filter
scopes to liked clips (liked: "True").
Returns the clips paired with a complete flag that is true only when
paging ended because the server reported has_more == false (the feed
fully drained). A missing has_more, a has_more == true page with no
usable next_cursor, a limit stop, exhausting [MAX_PAGES], or any
transport error all yield false (or propagate) so the caller can refuse
to treat a truncated listing as authoritative for deletion.
Sourcepub async fn get_clip(&self, http: &impl Http, id: &str) -> Result<Clip>
pub async fn get_clip(&self, http: &impl Http, id: &str) -> Result<Clip>
Fetch one clip by ID.
Tries the dedicated /api/clip/{id} endpoint first, then falls back to
scanning the library feed if that endpoint yields no matching clip.
Sourcepub async fn request_wav(&self, http: &impl Http, id: &str) -> Result<()>
pub async fn request_wav(&self, http: &impl Http, id: &str) -> Result<()>
Ask Suno to render a clip to lossless WAV (server-side, asynchronous).
Sourcepub async fn wav_url(
&self,
http: &impl Http,
id: &str,
) -> Result<Option<String>>
pub async fn wav_url( &self, http: &impl Http, id: &str, ) -> Result<Option<String>>
Read the rendered WAV URL for a clip, or None while it is not ready.
A 404 maps to None (the render is absent, not yet requested, or the
endpoint has moved), symmetric with aligned_lyrics
so an unrendered clip is “no WAV yet” rather than a run-aborting error.
Like request_wav it skips the shared retry: the
caller’s poll loop owns that budget.
Sourcepub async fn aligned_lyrics(
&self,
http: &impl Http,
id: &str,
) -> Result<AlignedLyrics>
pub async fn aligned_lyrics( &self, http: &impl Http, id: &str, ) -> Result<AlignedLyrics>
Fetch a clip’s word- and line-level aligned (synced) lyrics.
GET /api/gen/{id}/aligned_lyrics/v2/ (the trailing slash is required) on
the studio-api host, authenticated with the same JWT as every other
library read. The v2 shape carries both a flat word-level list and a
line-level list with section labels and nested per-word timing (see
AlignedLyrics).
An instrumental or un-alignable clip returns 200 with empty arrays,
which maps to an empty AlignedLyrics; a 404 (no alignment for the
clip) is treated the same way, so an absent endpoint is “no synced
lyrics” rather than a run failure — the caller then writes no synced
artefact, exactly as an empty cover URL writes no cover. Rides the
adaptive rate limiter like the other reads.
Sourcepub async fn get_clips_by_ids(
&self,
http: &impl Http,
ids: &[&str],
concurrency: usize,
) -> Result<Vec<Clip>>
pub async fn get_clips_by_ids( &self, http: &impl Http, ids: &[&str], concurrency: usize, ) -> Result<Vec<Clip>>
Fetch specific clips by id, batch-first with a per-id fallback.
Used by lineage resolution to gap-fill ancestors that are absent from a
normal listing, including trashed ones. Ids are fetched in a single
batch via get_songs_by_ids
(GET /api/clips/get_songs_by_ids), which cuts the round-trips and 429s
of one request per id. Any ids the batch does not return (individually
trashed or absent, exactly as a /api/clip/{id} 404 today, or in a
chunk the batch endpoint could not serve) then fall back to one
GET /api/clip/{id} each, with bounded concurrency, attempted exactly
once, and a 404 there is skipped so the caller can fall back to the
parent endpoint. A 429 while batching propagates rather than fanning
out into per-id requests.
Unlike list_clips, no downloadability filter is
applied: an ancestor may itself be an infill or context-window artefact
that the lineage walk must still traverse. Clips returned here are
ancestors for resolution only and must never be treated as download
candidates. Ids are deduplicated in order and the result preserves that
de-duplicated input order, matched by id (never by response position).
The signature is unchanged so gap_fill is unaffected.
Sourcepub async fn get_songs_by_ids(
&self,
http: &impl Http,
ids: &[&str],
) -> Result<Vec<Clip>>
pub async fn get_songs_by_ids( &self, http: &impl Http, ids: &[&str], ) -> Result<Vec<Clip>>
Batch-fetch clips by id via GET /api/clips/get_songs_by_ids?ids=…&ids=….
This is the pure batch primitive: the deduplicated ids are split into
chunks of [GET_SONGS_CHUNK], each requested with repeated ids= params,
and the {"clips":[…]} body is parsed defensively and matched back to the
requested ids by id, so the result preserves the de-duplicated input order
regardless of the server’s ordering and drops any clip that was not asked
for. Ids the batch does not return (trashed, absent, or in a chunk the
endpoint could not serve) are simply left out; filling them is the
caller’s job (see get_clips_by_ids).
The batch endpoint is undocumented and may be unavailable. A chunk that
the endpoint cannot serve (a 404, a 400, a 5xx, a transport failure,
or a body that is not {"clips":[…]}) yields nothing for that chunk
rather than erroring, so an outage or reshape degrades rather than breaks
(the decoupling rule) and the caller’s per-id fallback recovers those ids
exactly once. A 429, by contrast, rides the retry inside
api_get_retrying and, once exhausted,
propagates rather than letting a burst of per-id requests deepen the
throttling; an auth failure likewise propagates rather than being masked.
Sourcepub async fn get_clip_parent(
&self,
http: &impl Http,
id: &str,
) -> Result<Option<Clip>>
pub async fn get_clip_parent( &self, http: &impl Http, id: &str, ) -> Result<Option<Clip>>
Fetch a clip’s immediate parent via the dedicated parent endpoint.
Returns the parent clip, or None when the clip is a root. A root’s
parent is reported as HTTP 200 with a bodiless clip that carries no
id (e.g. {"is_public": false}), not a 404: [parse_clip] requires
a non-empty id, so that root shape maps to Ok(None) here. The 404
arm is kept as a belt-and-braces fallback for the alternative “no parent”
encoding. Any other failure, including a transient 5xx, propagates as
an error rather than being mistaken for a root.
Sourcepub async fn get_playlists(&self, http: &impl Http) -> Result<Vec<Playlist>>
pub async fn get_playlists(&self, http: &impl Http) -> Result<Vec<Playlist>>
List the account’s own playlists, paging /api/playlist/me.
Trashed and share-list playlists are excluded by query, so the result is
the account’s authoritative own set. Paging stops on the first empty page
and is hard-capped at [MAX_PAGES] so a server that ignores the page
parameter cannot loop forever. Only entries with a non-empty id are kept,
and accumulated entries are de-duplicated by id so a server that ignores
the page parameter and repeats a body cannot inflate the set.
A hard failure propagates as an error; the caller treats that as “the
playlist listing did not fully enumerate” and refuses every playlist
deletion this run, so a dropped fetch can never remove a .m3u8.
Sourcepub async fn get_playlist_clips(
&self,
http: &impl Http,
id: &str,
) -> Result<(Vec<Clip>, bool)>
pub async fn get_playlist_clips( &self, http: &impl Http, id: &str, ) -> Result<(Vec<Clip>, bool)>
Fetch one playlist’s clips in Suno order via /api/playlist/{id}/.
The response’s playlist_clips[] is already ordered and trashed members
are excluded by Suno, so the order is preserved exactly and no
downloadability filter is applied: a playlist may legitimately contain any
clip. Each entry’s clip object is mapped (falling back to the entry
itself), and only clips with a non-empty id are kept.
The returned bool is a completeness signal for deletion authority: the
endpoint reports num_total_results (the playlist’s full member count)
alongside playlist_clips[], so true means every member came back on
this single page intact (num_total_results present, equal to the raw
count, and no member dropped for a missing/empty id). A short page, or one
missing a member’s id, returns false, so a Mirror playlist area under
library = "off" is never treated as authoritative unless its whole
member set was seen (D5).
Sourcepub async fn get_billing_info(&self, http: &impl Http) -> Result<BillingInfo>
pub async fn get_billing_info(&self, http: &impl Http) -> Result<BillingInfo>
Read the authenticated account’s billing information.
Sourcepub async fn list_stems(
&self,
http: &impl Http,
clip_id: &str,
) -> Result<(Vec<Stem>, bool)>
pub async fn list_stems( &self, http: &impl Http, clip_id: &str, ) -> Result<(Vec<Stem>, bool)>
List a clip’s already-separated stems (free, read-only).
Uses the live stems shape: first GET /api/clip/{id}/stems/pages for the
page count ({"pages": N}), then GET /api/clip/{id}/stems?page=P for
each P in 0..N (the pages are 0-indexed), whose body is
{"stems": [<clip>, ...]} where each stem is a full clip object. Every
request rides the shared limiter and retry. This endpoint only reads: it
never spends credits and never triggers separation, so it is safe on the
bulk mirror path. The caller must only invoke it when the clip’s
has_stem is true.
Returns the collected stems paired with a complete flag that is true
only when the listing was fully and authoritatively enumerated: the page
count came back and every one of its pages drained, AFTER at least one
stem was seen. This encodes the deletion-safety invariant: an empty
listing (pages == 0, or a 400/404 on the page-count endpoint, which
Suno returns for a clip with zero stems), a transport failure, or a
partial drain (a page error mid-enumeration surfaces as Err) all yield a
non-authoritative result, so the caller KEEPS any existing local stems and
never reads the absence as “no stems”. A clip that declares more than
[MAX_PAGES] pages is likewise a truncated listing and never authoritative.
A stem is only ever removed from an authoritative (complete) listing that
omits it, or when its owning clip’s audio is deleted.