Skip to main content

suno_core/
client.rs

1//! The Suno API client: lists the library behind the [`Http`](crate::Http) port.
2
3use 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_PAGE_DELAY, 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::model::Clip;
18
19/// One of the account's own playlists, as listed by `/api/playlist/me`.
20///
21/// Carries only what playlist reconciliation needs: the stable id (the state
22/// key), the display name (drives the `.m3u8` file name and `#PLAYLIST` line),
23/// and the member count for reporting. The ordered members are fetched
24/// separately with [`SunoClient::get_playlist_clips`].
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Playlist {
27    /// The playlist's stable Suno id.
28    pub id: String,
29    /// The playlist's display name.
30    pub name: String,
31    /// The number of clips Suno reports in the playlist.
32    pub num_clips: u64,
33}
34
35/// A client for the Suno library API, owning the account's [`ClerkAuth`].
36///
37/// The [`Clock`] is held so [`api_request`](Self::api_request) can back off
38/// through the port on a `429` or transient failure, and paged listings can
39/// pace themselves under Suno's rate limiter — the engine still sleeps nowhere
40/// itself.
41pub struct SunoClient<C> {
42    auth: ClerkAuth,
43    clock: C,
44}
45
46impl<C: Clock> SunoClient<C> {
47    /// Create a client from a fresh or already-authenticated [`ClerkAuth`].
48    pub fn new(auth: ClerkAuth, clock: C) -> Self {
49        Self { auth, clock }
50    }
51
52    /// Borrow the underlying authenticator.
53    pub fn auth(&self) -> &ClerkAuth {
54        &self.auth
55    }
56
57    /// List clips across the whole library, or only liked clips.
58    ///
59    /// Walks the cursor-paginated `POST /api/feed/v3` feed, following
60    /// `next_cursor` until the server reports the end. Once `limit` clips have
61    /// been collected it stops at the next page boundary and truncates to
62    /// `limit`. Paging is hard-capped at [`MAX_PAGES`] so a runaway
63    /// `has_more` can never loop forever. When `liked` is set the feed filter
64    /// scopes to liked clips (`liked: "True"`).
65    ///
66    /// Returns the clips paired with a `complete` flag that is `true` only when
67    /// paging ended because the server reported `has_more == false` (the feed
68    /// fully drained). A missing `has_more`, a `has_more == true` page with no
69    /// usable `next_cursor`, a `limit` stop, exhausting [`MAX_PAGES`], or any
70    /// transport error all yield `false` (or propagate) so the caller can refuse
71    /// to treat a truncated listing as authoritative for deletion.
72    pub async fn list_clips(
73        &mut self,
74        http: &impl Http,
75        liked: bool,
76        limit: Option<usize>,
77    ) -> Result<(Vec<Clip>, bool)> {
78        let mut clips = Vec::new();
79        let mut cursor: Option<String> = None;
80        let mut complete = false;
81        for page in 0..MAX_PAGES {
82            if page > 0 {
83                self.clock.sleep(FEED_PAGE_DELAY).await;
84            }
85            let body = feed_v3_body(liked, cursor.as_deref());
86            let response = self
87                .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
88                .await?;
89            let (page_clips, has_more, next_cursor) = parse_feed_v3(&response)?;
90            clips.extend(page_clips);
91            match has_more {
92                Some(false) => {
93                    complete = true;
94                    break;
95                }
96                Some(true) => match next_cursor {
97                    Some(next) => cursor = Some(next),
98                    None => break,
99                },
100                None => break,
101            }
102            if limit.is_some_and(|n| clips.len() >= n) {
103                break;
104            }
105        }
106        if let Some(n) = limit {
107            clips.truncate(n);
108        }
109        Ok((clips, complete))
110    }
111
112    /// Fetch one clip by ID.
113    ///
114    /// Tries the dedicated `/api/clip/{id}` endpoint first, then falls back to
115    /// scanning the library feed, since that endpoint's exact shape is not yet
116    /// confirmed against the live API.
117    pub async fn get_clip(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
118        if let Some(clip) = self.try_get_clip(http, id).await? {
119            return Ok(clip);
120        }
121        self.find_in_feed(http, id).await
122    }
123
124    /// Ask Suno to render a clip to lossless WAV (server-side, asynchronous).
125    pub async fn request_wav(&mut self, http: &impl Http, id: &str) -> Result<()> {
126        let path = format!("/api/gen/{id}/convert_wav/");
127        self.api_request(http, Method::Post, &path, Vec::new())
128            .await?;
129        Ok(())
130    }
131
132    /// Read the rendered WAV URL for a clip, or `None` while it is not ready.
133    pub async fn wav_url(&mut self, http: &impl Http, id: &str) -> Result<Option<String>> {
134        let path = format!("/api/gen/{id}/wav_file/");
135        let body = self.api_get(http, &path).await?;
136        let data: Value = serde_json::from_slice(&body)
137            .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
138        Ok(data
139            .get("wav_file_url")
140            .and_then(Value::as_str)
141            .filter(|url| !url.is_empty())
142            .map(str::to_string))
143    }
144
145    /// Fetch specific clips by id, one `GET /api/clip/{id}` per id.
146    ///
147    /// Used by lineage resolution to gap-fill ancestors that are absent from a
148    /// normal listing, including trashed ones. The v3 feed has no batch by-id
149    /// filter, so each id is fetched individually; `/api/clip/{id}` returns any
150    /// clip, trashed or artefact, with the full field set. Unlike
151    /// [`list_clips`](Self::list_clips), no downloadability filter is applied: an
152    /// ancestor may itself be an infill or context-window artefact that the
153    /// lineage walk must still traverse. Clips returned here are ancestors for
154    /// resolution only and must never be treated as download candidates. Ids are
155    /// deduplicated in order, and an id that cannot be retrieved (a `404`) is
156    /// skipped so the caller can fall back to the parent endpoint.
157    pub async fn get_clips_by_ids(&mut self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
158        let mut clips = Vec::new();
159        let mut seen: BTreeSet<&str> = BTreeSet::new();
160        for id in ids {
161            if id.is_empty() || !seen.insert(id) {
162                continue;
163            }
164            let path = format!("/api/clip/{id}");
165            match self.api_get_retrying(http, &path).await {
166                Ok(body) => {
167                    if let Some(clip) = parse_clip(&body) {
168                        clips.push(clip);
169                    }
170                }
171                Err(Error::NotFound(_)) => continue,
172                Err(err) => return Err(err),
173            }
174        }
175        Ok(clips)
176    }
177
178    /// Fetch a clip's immediate parent via the dedicated parent endpoint.
179    ///
180    /// Returns the parent clip, or `None` when the clip is a root (no parent) or
181    /// the endpoint yields no clip. Lineage resolution uses this as a fallback
182    /// when a missing ancestor cannot be retrieved by id. Only a `404` (the clip
183    /// has no parent) maps to `None`; any other failure, including a transient
184    /// `5xx`, propagates as an error rather than being mistaken for a root.
185    pub async fn get_clip_parent(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
186        let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
187        match self.api_get_retrying(http, &path).await {
188            Ok(body) => Ok(parse_clip(&body)),
189            Err(Error::NotFound(_)) => Ok(None),
190            Err(err) => Err(err),
191        }
192    }
193
194    /// List the account's own playlists, paging `/api/playlist/me`.
195    ///
196    /// Trashed and share-list playlists are excluded by query, so the result is
197    /// the account's authoritative own set. Paging stops on the first empty page
198    /// and is hard-capped at [`MAX_PAGES`] so a server that ignores the page
199    /// parameter cannot loop forever. Only entries with a non-empty id are kept.
200    ///
201    /// A hard failure propagates as an error; the caller treats that as "the
202    /// playlist listing did not fully enumerate" and refuses every playlist
203    /// deletion this run, so a dropped fetch can never remove a `.m3u8`.
204    pub async fn get_playlists(&mut self, http: &impl Http) -> Result<Vec<Playlist>> {
205        let mut playlists = Vec::new();
206        for page in 1..=MAX_PAGES {
207            if page > 1 {
208                self.clock.sleep(FEED_PAGE_DELAY).await;
209            }
210            let path =
211                format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
212            let body = self.api_get_retrying(http, &path).await?;
213            let page_playlists = parse_playlists(&body)?;
214            if page_playlists.is_empty() {
215                break;
216            }
217            playlists.extend(page_playlists);
218        }
219        Ok(playlists)
220    }
221
222    /// Fetch one playlist's clips in Suno order via `/api/playlist/{id}/`.
223    ///
224    /// The response's `playlist_clips[]` is already ordered and trashed members
225    /// are excluded by Suno, so the order is preserved exactly and no
226    /// downloadability filter is applied: a playlist may legitimately contain any
227    /// clip. Each entry's `clip` object is mapped (falling back to the entry
228    /// itself), and only clips with a non-empty id are kept.
229    pub async fn get_playlist_clips(&mut self, http: &impl Http, id: &str) -> Result<Vec<Clip>> {
230        let path = format!("{PLAYLIST_PATH}{id}/");
231        let body = self.api_get_retrying(http, &path).await?;
232        parse_playlist_clips(&body)
233    }
234
235    /// Try the dedicated clip endpoint, returning `None` when it is missing or
236    /// returns a body that does not yield the requested clip.
237    async fn try_get_clip(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
238        let path = format!("/api/clip/{id}");
239        match self.api_get_retrying(http, &path).await {
240            Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
241            Err(Error::NotFound(_)) => Ok(None),
242            Err(err) => Err(err),
243        }
244    }
245
246    /// Locate a clip by scanning the library feed.
247    async fn find_in_feed(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
248        let (clips, _complete) = self.list_clips(http, false, None).await?;
249        clips
250            .into_iter()
251            .find(|clip| clip.id == id)
252            .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
253    }
254
255    /// Perform an authenticated GET, refreshing the JWT once on a 401/403.
256    async fn api_get(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
257        self.api_request(http, Method::Get, path, Vec::new()).await
258    }
259
260    /// A retrying GET: [`api_send_retrying`](Self::api_send_retrying) with no body.
261    async fn api_get_retrying(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
262        self.api_send_retrying(http, Method::Get, path, Vec::new())
263            .await
264    }
265
266    /// Like [`api_request`](Self::api_request) but rides through Suno's rate
267    /// limiter, backing off through the [`Clock`] on a `429` (honouring
268    /// `Retry-After` when present) or a transient connection failure, up to
269    /// [`API_MAX_RETRIES`] times. Each attempt reconstructs the full request
270    /// (method, path, and body), so a throttled feed page re-POSTs the same
271    /// cursor rather than skipping ahead.
272    ///
273    /// The WAV render flow deliberately keeps to the plain [`api_get`](Self::api_get):
274    /// the executor owns that retry so its budget and poll interval stay in one
275    /// place. Library, playlist, and lineage reads use this so a full-library
276    /// walk is not aborted by a single throttled page.
277    async fn api_send_retrying(
278        &mut self,
279        http: &impl Http,
280        method: Method,
281        path: &str,
282        body: Vec<u8>,
283    ) -> Result<Vec<u8>> {
284        let mut retries = 0;
285        loop {
286            match self.api_request(http, method, path, body.clone()).await {
287                Ok(response) => return Ok(response),
288                Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
289                    self.clock.sleep(backoff_delay(retries, retry_after)).await;
290                    retries += 1;
291                }
292                Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
293                    self.clock.sleep(backoff_delay(retries, None)).await;
294                    retries += 1;
295                }
296                Err(err) => return Err(err),
297            }
298        }
299    }
300
301    /// Perform an authenticated request, refreshing the JWT once on a 401/403.
302    ///
303    /// `body` is sent only by the adapter when non-empty, so a GET or a bodyless
304    /// POST reaches the network unchanged.
305    async fn api_request(
306        &mut self,
307        http: &impl Http,
308        method: Method,
309        path: &str,
310        body: Vec<u8>,
311    ) -> Result<Vec<u8>> {
312        let url = format!("{SUNO_API_BASE_URL}{path}");
313        let mut auth_refreshed = false;
314        loop {
315            let jwt = self.auth.ensure_jwt(http).await?;
316            let mut request = match method {
317                Method::Get => HttpRequest::get(url.clone()),
318                Method::Post => HttpRequest::post(url.clone(), body.clone()),
319            };
320            request
321                .headers
322                .push(("Authorization".to_string(), format!("Bearer {jwt}")));
323            let response = http
324                .send(request)
325                .await
326                .map_err(|err| Error::Connection(err.to_string()))?;
327            match response.status {
328                200..=299 => return Ok(response.body),
329                401 | 403 if !auth_refreshed => {
330                    self.auth.invalidate_jwt();
331                    auth_refreshed = true;
332                }
333                401 | 403 => {
334                    return Err(Error::Auth(format!(
335                        "Suno API auth failed with status {}",
336                        response.status
337                    )));
338                }
339                429 => {
340                    return Err(Error::RateLimited {
341                        retry_after: retry_after(&response),
342                    });
343                }
344                404 => {
345                    return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
346                }
347                status => {
348                    let preview: String = String::from_utf8_lossy(&response.body)
349                        .chars()
350                        .take(200)
351                        .collect();
352                    return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
353                }
354            }
355        }
356    }
357}
358
359/// Parse a single-clip response body, accepting either a bare clip object or a
360/// `{"clip": {...}}` wrapper. Returns `None` when no clip id is present.
361fn parse_clip(body: &[u8]) -> Option<Clip> {
362    let data: Value = serde_json::from_slice(body).ok()?;
363    let raw = data
364        .get("clip")
365        .filter(|value| value.is_object())
366        .unwrap_or(&data);
367    let has_id = raw
368        .get("id")
369        .and_then(Value::as_str)
370        .is_some_and(|id| !id.is_empty());
371    has_id.then(|| Clip::from_json(raw))
372}
373
374/// Build the JSON body for a `POST /api/feed/v3` page.
375///
376/// `filters.trashed` is the string `"False"` so the feed excludes trashed clips
377/// exactly as the old v2 listing did; a `liked` walk adds `filters.liked =
378/// "True"` (v3 ignores an `is_liked` key). The `cursor` is omitted on the first
379/// page and set to the previous page's `next_cursor` thereafter.
380fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
381    let mut filters = serde_json::Map::new();
382    filters.insert("trashed".to_string(), Value::String("False".to_string()));
383    if liked {
384        filters.insert("liked".to_string(), Value::String("True".to_string()));
385    }
386    let mut body = serde_json::Map::new();
387    body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
388    body.insert("filters".to_string(), Value::Object(filters));
389    if let Some(cursor) = cursor {
390        body.insert("cursor".to_string(), Value::String(cursor.to_string()));
391    }
392    serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
393}
394
395/// Parse a v3 feed page into the kept clips, the raw `has_more`, and the
396/// `next_cursor`.
397///
398/// `has_more` is [`None`] when the key is missing or not a bool, so the caller
399/// can refuse to treat an unrecognised page as a fully drained feed. An empty
400/// `next_cursor` string maps to [`None`] so it is never re-sent as a cursor.
401fn parse_feed_v3(body: &[u8]) -> Result<(Vec<Clip>, Option<bool>, Option<String>)> {
402    let data: Value = serde_json::from_slice(body)
403        .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
404    let Some(object) = data.as_object() else {
405        return Ok((Vec::new(), None, None));
406    };
407    let clips = object
408        .get("clips")
409        .and_then(Value::as_array)
410        .map(|raw| {
411            raw.iter()
412                .map(Clip::from_json)
413                .filter(is_downloadable)
414                .collect()
415        })
416        .unwrap_or_default();
417    let has_more = object.get("has_more").and_then(Value::as_bool);
418    let next_cursor = object
419        .get("next_cursor")
420        .and_then(Value::as_str)
421        .filter(|cursor| !cursor.is_empty())
422        .map(str::to_string);
423    Ok((clips, has_more, next_cursor))
424}
425
426/// Parse a `/api/playlist/me` page into playlists, dropping entries with no id.
427fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
428    let data: Value = serde_json::from_slice(body)
429        .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
430    Ok(data
431        .get("playlists")
432        .and_then(Value::as_array)
433        .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
434        .unwrap_or_default())
435}
436
437/// Map one raw `/api/playlist/me` entry, or `None` when it carries no id.
438///
439/// `num_total_results` is the playlist's member count; a missing name defaults
440/// to `Untitled` (matching the clip mapping) so the file name is never empty.
441fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
442    let id = raw
443        .get("id")
444        .and_then(Value::as_str)
445        .filter(|id| !id.is_empty())?
446        .to_string();
447    let name = match raw.get("name") {
448        Some(Value::String(name)) if !name.is_empty() => name.clone(),
449        _ => "Untitled".to_string(),
450    };
451    let num_clips = raw
452        .get("num_total_results")
453        .and_then(Value::as_u64)
454        .unwrap_or(0);
455    Some(Playlist {
456        id,
457        name,
458        num_clips,
459    })
460}
461
462/// Parse a `/api/playlist/{id}/` body into its ordered member clips.
463///
464/// Each `playlist_clips[]` entry wraps the clip under `clip`; the wrapper is
465/// unwrapped (falling back to the entry itself), order is preserved exactly, and
466/// only clips with a non-empty id survive. No downloadability filter is applied:
467/// a playlist may hold any clip, and members absent from the local library are
468/// reconciled as comment lines by the caller, not dropped here. The scoped-sync
469/// path applies [`is_downloadable`](crate::is_downloadable) itself when it fetches
470/// members as download candidates.
471fn parse_playlist_clips(body: &[u8]) -> Result<Vec<Clip>> {
472    let data: Value = serde_json::from_slice(body)
473        .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
474    Ok(data
475        .get("playlist_clips")
476        .and_then(Value::as_array)
477        .map(|raw| {
478            raw.iter()
479                .map(|entry| {
480                    let clip = entry
481                        .get("clip")
482                        .filter(|value| value.is_object())
483                        .unwrap_or(entry);
484                    Clip::from_json(clip)
485                })
486                .filter(|clip| !clip.id.is_empty())
487                .collect()
488        })
489        .unwrap_or_default())
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
496    use std::time::Duration;
497
498    fn feed_body() -> String {
499        serde_json::json!({
500            "has_more": false,
501            "clips": [
502                {
503                    "id": "a", "title": "Song A", "status": "complete",
504                    "audio_url": "https://cdn1.suno.ai/a.mp3",
505                    "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
506                },
507                {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
508                {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
509                {
510                    "id": "d", "title": "Context", "status": "complete",
511                    "metadata": {"type": "rendered_context_window"}
512                }
513            ]
514        })
515        .to_string()
516    }
517
518    #[test]
519    fn parse_feed_v3_filters_and_reads_pagination() {
520        let (clips, has_more, next_cursor) = parse_feed_v3(feed_body().as_bytes()).unwrap();
521        assert_eq!(has_more, Some(false));
522        assert_eq!(next_cursor, None);
523        assert_eq!(clips.len(), 1);
524        assert_eq!(clips[0].id, "a");
525        assert_eq!(clips[0].tags, "rock");
526        assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
527    }
528
529    #[test]
530    fn feed_v3_body_carries_filters_and_optional_cursor() {
531        let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
532        assert_eq!(first["filters"]["trashed"], "False");
533        assert!(first.get("cursor").is_none());
534        assert!(first["filters"].get("liked").is_none());
535
536        let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
537        assert_eq!(liked["filters"]["liked"], "True");
538        assert_eq!(liked["cursor"], "cur42");
539    }
540
541    #[test]
542    fn audiopipe_url_is_rewritten_to_cdn() {
543        let raw =
544            serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
545        assert_eq!(
546            Clip::from_json(&raw).audio_url,
547            "https://cdn1.suno.ai/x.mp3"
548        );
549    }
550
551    #[test]
552    fn list_clips_authenticates_then_reads_the_feed() {
553        let client_body = serde_json::json!({
554            "response": {
555                "last_active_session_id": "s",
556                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
557            }
558        })
559        .to_string();
560        let http = MockHttp::new(vec![
561            Rule::new(
562                "/v1/client/sessions/",
563                200,
564                r#"{"jwt": "a.b.c"}"#.to_string(),
565            ),
566            Rule::new("/v1/client", 200, client_body),
567            Rule::new("/api/feed/v3", 200, feed_body()),
568        ]);
569
570        let mut auth = ClerkAuth::new("eyJtoken");
571        pollster::block_on(auth.authenticate(&http)).unwrap();
572        let mut client = SunoClient::new(auth, RecordingClock::new());
573        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
574        assert_eq!(clips.len(), 1);
575        assert_eq!(clips[0].id, "a");
576        assert!(complete);
577    }
578
579    #[test]
580    fn list_clips_reports_incomplete_when_paging_is_capped() {
581        let mut rules = auth_rules();
582        rules.push(Rule::new(
583            "/api/feed/v3",
584            200,
585            serde_json::json!({
586                "has_more": true,
587                "next_cursor": "cur1",
588                "clips": [{
589                    "id": "a", "title": "Song A", "status": "complete",
590                    "audio_url": "https://cdn1.suno.ai/a.mp3",
591                    "metadata": {"type": "gen"}
592                }]
593            })
594            .to_string(),
595        ));
596        let http = MockHttp::new(rules);
597        let mut client = authed_client(&http);
598
599        let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
600        assert!(!complete);
601    }
602
603    fn auth_rules() -> Vec<Rule> {
604        let client_body = serde_json::json!({
605            "response": {
606                "last_active_session_id": "s",
607                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
608            }
609        })
610        .to_string();
611        vec![
612            Rule::new(
613                "/v1/client/sessions/",
614                200,
615                r#"{"jwt": "a.b.c"}"#.to_string(),
616            ),
617            Rule::new("/v1/client", 200, client_body),
618        ]
619    }
620
621    fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
622        let mut auth = ClerkAuth::new("eyJtoken");
623        pollster::block_on(auth.authenticate(http)).unwrap();
624        SunoClient::new(auth, RecordingClock::new())
625    }
626
627    fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
628        let mut auth = ClerkAuth::new("eyJtoken");
629        pollster::block_on(auth.authenticate(http)).unwrap();
630        SunoClient::new(auth, clock)
631    }
632
633    fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
634        let mut page = serde_json::json!({
635            "has_more": next_cursor.is_some(),
636            "clips": [{
637                "id": id, "title": "Song", "status": "complete",
638                "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
639                "metadata": {"type": "gen"}
640            }]
641        });
642        if let Some(cursor) = next_cursor {
643            page["next_cursor"] = serde_json::json!(cursor);
644        }
645        page.to_string()
646    }
647
648    #[test]
649    fn list_clips_retries_a_rate_limited_page() {
650        let http = ScriptedHttp::new().with_auth().route_seq(
651            "/api/feed/v3",
652            vec![Reply::status(429), Reply::json(&feed_body())],
653        );
654        let clock = RecordingClock::new();
655        let mut client = scripted_client(&http, clock.clone());
656
657        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
658        assert_eq!(clips.len(), 1);
659        assert!(complete);
660        // The throttled page was retried once, waiting out one base backoff.
661        assert_eq!(http.count("/api/feed/v3"), 2);
662        assert_eq!(clock.sleeps(), vec![Duration::from_secs(1)]);
663    }
664
665    #[test]
666    fn list_clips_honours_retry_after_on_a_throttled_page() {
667        let http = ScriptedHttp::new().with_auth().route_seq(
668            "/api/feed/v3",
669            vec![
670                Reply::status(429).with_retry_after(7),
671                Reply::json(&feed_body()),
672            ],
673        );
674        let clock = RecordingClock::new();
675        let mut client = scripted_client(&http, clock.clone());
676
677        let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
678        assert_eq!(clips.len(), 1);
679        // The server's Retry-After floors the backoff above the 1s base.
680        assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
681    }
682
683    #[test]
684    fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
685        // A 429 mid-walk must re-POST the *same* cursor, not skip a page.
686        let http = ScriptedHttp::new().with_auth().route_seq(
687            "/api/feed/v3",
688            vec![
689                Reply::json(&one_clip_page("a", Some("cur1"))),
690                Reply::status(429),
691                Reply::json(&one_clip_page("b", None)),
692            ],
693        );
694        let clock = RecordingClock::new();
695        let mut client = scripted_client(&http, clock.clone());
696
697        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
698        assert!(complete);
699        assert_eq!(clips.len(), 2);
700        let bodies = http.bodies();
701        let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
702        assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
703        // The retry (body 2) carries the SAME cursor as the throttled call (body 2 == the
704        // second feed POST), i.e. the cursor from page 1's next_cursor.
705        let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
706        let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
707        assert_eq!(retried["cursor"], "cur1");
708        assert_eq!(after_retry["cursor"], "cur1");
709    }
710
711    #[test]
712    fn list_clips_threads_the_cursor_across_pages() {
713        let http = ScriptedHttp::new().with_auth().route_seq(
714            "/api/feed/v3",
715            vec![
716                Reply::json(&one_clip_page("a", Some("cur1"))),
717                Reply::json(&one_clip_page("b", None)),
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!(complete);
725        assert_eq!(clips.len(), 2);
726        let bodies = http.bodies();
727        let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
728        assert_eq!(feed_bodies.len(), 2);
729        let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
730        let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
731        // Page 1 omits the cursor; page 2 carries exactly page 1's next_cursor.
732        assert!(page1.get("cursor").is_none());
733        assert_eq!(page2["cursor"], "cur1");
734    }
735
736    #[test]
737    fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
738        // has_more == true with no usable next_cursor: a truncated feed. The walk
739        // must stop, report incomplete, and never re-POST a null cursor.
740        let page = serde_json::json!({
741            "has_more": true,
742            "clips": [{
743                "id": "a", "title": "Song", "status": "complete",
744                "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
745            }]
746        })
747        .to_string();
748        let http = ScriptedHttp::new()
749            .with_auth()
750            .route("/api/feed/v3", Reply::json(&page));
751        let clock = RecordingClock::new();
752        let mut client = scripted_client(&http, clock.clone());
753
754        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
755        assert!(!complete);
756        assert_eq!(clips.len(), 1);
757        assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
758    }
759
760    #[test]
761    fn list_clips_is_incomplete_when_has_more_is_missing() {
762        // A page with no has_more key must not be read as a fully drained feed.
763        let page = serde_json::json!({
764            "clips": [{
765                "id": "a", "title": "Song", "status": "complete",
766                "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
767            }]
768        })
769        .to_string();
770        let http = ScriptedHttp::new()
771            .with_auth()
772            .route("/api/feed/v3", Reply::json(&page));
773        let clock = RecordingClock::new();
774        let mut client = scripted_client(&http, clock.clone());
775
776        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
777        assert!(!complete);
778        assert_eq!(clips.len(), 1);
779        assert_eq!(http.count("/api/feed/v3"), 1);
780    }
781
782    #[test]
783    fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
784        let http = ScriptedHttp::new().with_auth().route_seq(
785            "/api/feed/v3",
786            vec![
787                Reply::json(&one_clip_page("a", Some("cur1"))),
788                Reply::status(500),
789            ],
790        );
791        let clock = RecordingClock::new();
792        let mut client = scripted_client(&http, clock.clone());
793
794        let result = pollster::block_on(client.list_clips(&http, false, None));
795        assert!(matches!(result, Err(Error::Api(_))));
796    }
797
798    #[test]
799    fn list_clips_is_complete_on_an_empty_drained_feed() {
800        // An empty but fully drained feed is authoritative (complete = true);
801        // deletion is separately gated by there being a mirror source.
802        let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
803        let http = ScriptedHttp::new()
804            .with_auth()
805            .route("/api/feed/v3", Reply::json(&page));
806        let clock = RecordingClock::new();
807        let mut client = scripted_client(&http, clock.clone());
808
809        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
810        assert!(complete);
811        assert!(clips.is_empty());
812    }
813
814    #[test]
815    fn list_clips_liked_scope_sends_the_liked_filter() {
816        let http = ScriptedHttp::new()
817            .with_auth()
818            .route("/api/feed/v3", Reply::json(&feed_body()));
819        let clock = RecordingClock::new();
820        let mut client = scripted_client(&http, clock.clone());
821
822        let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
823        let bodies = http.bodies();
824        let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
825        let value: Value = serde_json::from_str(feed_body).unwrap();
826        assert_eq!(value["filters"]["liked"], "True");
827        assert_eq!(value["filters"]["trashed"], "False");
828    }
829
830    #[test]
831    fn list_clips_paces_between_pages() {
832        let http = ScriptedHttp::new().with_auth().route_seq(
833            "/api/feed/v3",
834            vec![
835                Reply::json(&one_clip_page("a", Some("cur1"))),
836                Reply::json(&one_clip_page("e", None)),
837            ],
838        );
839        let clock = RecordingClock::new();
840        let mut client = scripted_client(&http, clock.clone());
841
842        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
843        assert!(complete);
844        assert_eq!(clips.len(), 2);
845        assert_eq!(http.count("/api/feed/v3"), 2);
846        // One inter-page pace was waited out before fetching the second page.
847        assert_eq!(clock.sleeps(), vec![crate::consts::FEED_PAGE_DELAY]);
848    }
849
850    #[test]
851    fn list_clips_gives_up_after_max_retries() {
852        let http = ScriptedHttp::new()
853            .with_auth()
854            .route("/api/feed/v3", Reply::status(429));
855        let clock = RecordingClock::new();
856        let mut client = scripted_client(&http, clock.clone());
857
858        let result = pollster::block_on(client.list_clips(&http, false, None));
859        assert!(matches!(result, Err(Error::RateLimited { .. })));
860        let budget = crate::consts::API_MAX_RETRIES as usize;
861        assert_eq!(clock.sleeps().len(), budget);
862        assert_eq!(http.count("/api/feed/v3"), budget + 1);
863    }
864
865    #[test]
866    fn parse_clip_accepts_bare_and_wrapped_shapes() {
867        let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
868        assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
869
870        let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
871        assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
872
873        let missing = serde_json::json!({"detail": "not found"}).to_string();
874        assert!(parse_clip(missing.as_bytes()).is_none());
875    }
876
877    #[test]
878    fn get_clip_uses_the_dedicated_endpoint() {
879        let clip_body = serde_json::json!({
880            "id": "z", "title": "Zed", "status": "complete",
881            "audio_url": "https://cdn1.suno.ai/z.mp3",
882            "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
883        })
884        .to_string();
885        let mut rules = auth_rules();
886        rules.push(Rule::new("/api/clip/", 200, clip_body));
887        let http = MockHttp::new(rules);
888        let mut client = authed_client(&http);
889
890        let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
891        assert_eq!(clip.id, "z");
892        assert_eq!(clip.title, "Zed");
893        assert_eq!(clip.tags, "jazz");
894    }
895
896    #[test]
897    fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
898        let mut rules = auth_rules();
899        rules.push(Rule::new(
900            "/api/clip/",
901            404,
902            r#"{"detail": "not found"}"#.to_string(),
903        ));
904        rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
905        let http = MockHttp::new(rules);
906        let mut client = authed_client(&http);
907
908        let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
909        assert_eq!(clip.id, "a");
910        assert_eq!(clip.tags, "rock");
911    }
912
913    #[test]
914    fn request_wav_accepts_a_2xx_status() {
915        let mut rules = auth_rules();
916        rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
917        let http = MockHttp::new(rules);
918        let mut client = authed_client(&http);
919
920        assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
921    }
922
923    #[test]
924    fn wav_url_reads_the_ready_url() {
925        let mut rules = auth_rules();
926        rules.push(Rule::new(
927            "/wav_file/",
928            200,
929            r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
930        ));
931        let http = MockHttp::new(rules);
932        let mut client = authed_client(&http);
933
934        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
935        assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
936    }
937
938    #[test]
939    fn wav_url_is_none_until_the_render_is_ready() {
940        let mut rules = auth_rules();
941        rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
942        let http = MockHttp::new(rules);
943        let mut client = authed_client(&http);
944
945        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
946        assert_eq!(url, None);
947    }
948
949    #[test]
950    fn get_clips_by_ids_fetches_each_id_and_keeps_artefacts() {
951        // The per-id gap-fill path must not apply the listing's downloadability
952        // filter: an infill ancestor and an upload root both survive, fetched one
953        // `/api/clip/{id}` at a time.
954        let p1 = serde_json::json!({
955            "id": "p1", "title": "Infill Ancestor", "status": "complete",
956            "metadata": {"type": "gen", "task": "infill"}
957        })
958        .to_string();
959        let p2 = serde_json::json!({
960            "id": "p2", "title": "Uploaded Root", "status": "complete",
961            "metadata": {"type": "upload"}
962        })
963        .to_string();
964        let mut rules = auth_rules();
965        rules.push(Rule::new("/api/clip/p1", 200, p1));
966        rules.push(Rule::new("/api/clip/p2", 200, p2));
967        let http = MockHttp::new(rules);
968        let mut client = authed_client(&http);
969
970        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"])).unwrap();
971        assert_eq!(
972            clips.len(),
973            2,
974            "infill and upload ancestors must not be filtered"
975        );
976        assert_eq!(clips[0].id, "p1");
977        assert_eq!(clips[1].id, "p2");
978    }
979
980    #[test]
981    fn get_clips_by_ids_returns_a_trashed_clip() {
982        // A trashed ancestor must still be retrievable by id (the v2 `?ids=`
983        // capability that per-id `/api/clip/{id}` replaces).
984        let trashed = serde_json::json!({
985            "id": "t1", "title": "Trashed Ancestor", "status": "complete",
986            "is_trashed": true, "metadata": {"type": "gen"}
987        })
988        .to_string();
989        let mut rules = auth_rules();
990        rules.push(Rule::new("/api/clip/t1", 200, trashed));
991        let http = MockHttp::new(rules);
992        let mut client = authed_client(&http);
993
994        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"])).unwrap();
995        assert_eq!(clips.len(), 1);
996        assert_eq!(clips[0].id, "t1");
997        assert!(clips[0].is_trashed);
998    }
999
1000    #[test]
1001    fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
1002        let only = serde_json::json!({
1003            "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
1004        })
1005        .to_string();
1006        let http = ScriptedHttp::new()
1007            .with_auth()
1008            .route("/api/clip/gone", Reply::status(404))
1009            .route("/api/clip/only", Reply::json(&only));
1010        let mut client = scripted_client(&http, RecordingClock::new());
1011
1012        let clips =
1013            pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"])).unwrap();
1014        assert_eq!(clips.len(), 1, "the 404 id is skipped");
1015        assert_eq!(clips[0].id, "only");
1016        // "only" is fetched once despite appearing twice; "gone" is attempted once.
1017        assert_eq!(http.count("/api/clip/only"), 1);
1018        assert_eq!(http.count("/api/clip/gone"), 1);
1019    }
1020
1021    #[test]
1022    fn get_clip_parent_reads_the_parent_clip() {
1023        let parent = serde_json::json!({
1024            "id": "par", "title": "Ancestor", "status": "complete",
1025            "metadata": {"type": "gen"}
1026        })
1027        .to_string();
1028        let mut rules = auth_rules();
1029        rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
1030        let http = MockHttp::new(rules);
1031        let mut client = authed_client(&http);
1032
1033        let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
1034        assert_eq!(clip.unwrap().id, "par");
1035    }
1036
1037    #[test]
1038    fn get_clip_parent_is_none_for_a_root() {
1039        let mut rules = auth_rules();
1040        rules.push(Rule::new(
1041            "/api/clips/parent",
1042            404,
1043            r#"{"detail": "no parent"}"#.to_string(),
1044        ));
1045        let http = MockHttp::new(rules);
1046        let mut client = authed_client(&http);
1047
1048        let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
1049        assert!(clip.is_none());
1050    }
1051
1052    #[test]
1053    fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
1054        // A transient 5xx must never be mistaken for "this clip is a root":
1055        // folding it into Ok(None) would fabricate a wrong external root and let
1056        // a blip rewrite lineage (HARDENING H3). Only a real 404 means no parent.
1057        for status in [500u16, 503] {
1058            let mut rules = auth_rules();
1059            rules.push(Rule::new(
1060                "/api/clips/parent",
1061                status,
1062                r#"{"detail": "server error"}"#.to_string(),
1063            ));
1064            let http = MockHttp::new(rules);
1065            let mut client = authed_client(&http);
1066
1067            let result = pollster::block_on(client.get_clip_parent(&http, "child"));
1068            assert!(
1069                matches!(result, Err(Error::Api(_))),
1070                "status {status} must propagate as an error, not Ok(None)"
1071            );
1072        }
1073    }
1074
1075    #[test]
1076    fn get_playlists_maps_entries_and_skips_missing_ids() {
1077        let page1 = serde_json::json!({
1078            "playlists": [
1079                {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
1080                {"id": "", "name": "No Id", "num_total_results": 3},
1081                {"name": "Also No Id"}
1082            ]
1083        })
1084        .to_string();
1085        let mut rules = auth_rules();
1086        // Page 1 returns entries; page 2 is empty, ending pagination.
1087        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1088        rules.push(Rule::new(
1089            "/api/playlist/me?page=2",
1090            200,
1091            r#"{"playlists": []}"#.to_string(),
1092        ));
1093        let http = MockHttp::new(rules);
1094        let mut client = authed_client(&http);
1095
1096        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1097        assert_eq!(playlists.len(), 1, "entries without an id are dropped");
1098        assert_eq!(
1099            playlists[0],
1100            Playlist {
1101                id: "pl1".to_owned(),
1102                name: "Road Trip".to_owned(),
1103                num_clips: 12,
1104            }
1105        );
1106    }
1107
1108    #[test]
1109    fn get_playlists_defaults_a_missing_name_to_untitled() {
1110        let page1 = serde_json::json!({
1111            "playlists": [{"id": "pl9", "num_total_results": 1}]
1112        })
1113        .to_string();
1114        let mut rules = auth_rules();
1115        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1116        rules.push(Rule::new(
1117            "/api/playlist/me?page=2",
1118            200,
1119            r#"{"playlists": []}"#.to_string(),
1120        ));
1121        let http = MockHttp::new(rules);
1122        let mut client = authed_client(&http);
1123
1124        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1125        assert_eq!(playlists[0].name, "Untitled");
1126    }
1127
1128    #[test]
1129    fn get_playlist_clips_preserves_order_and_unwraps_clip() {
1130        // Members arrive wrapped under `clip`, in playlist order, already
1131        // non-trashed. Order is preserved and no downloadability filter is applied.
1132        let body = serde_json::json!({
1133            "playlist_clips": [
1134                {"clip": {
1135                    "id": "second", "title": "Second", "status": "complete",
1136                    "metadata": {"duration": 60.0, "type": "gen"}
1137                }},
1138                {"clip": {
1139                    "id": "first", "title": "First", "status": "complete",
1140                    "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
1141                }}
1142            ]
1143        })
1144        .to_string();
1145        let mut rules = auth_rules();
1146        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1147        let http = MockHttp::new(rules);
1148        let mut client = authed_client(&http);
1149
1150        let clips = pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1151        assert_eq!(clips.len(), 2, "an infill member is not filtered out");
1152        assert_eq!(clips[0].id, "second");
1153        assert_eq!(clips[1].id, "first");
1154    }
1155
1156    #[test]
1157    fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
1158        let mut rules = auth_rules();
1159        rules.push(Rule::new(
1160            "/api/playlist/empty/",
1161            200,
1162            r#"{"playlist_clips": []}"#.to_string(),
1163        ));
1164        let http = MockHttp::new(rules);
1165        let mut client = authed_client(&http);
1166
1167        let clips = pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
1168        assert!(clips.is_empty());
1169    }
1170}