Skip to main content

suno_core/
client.rs

1//! The Suno API client: lists the library behind the [`Http`](crate::Http) port.
2
3use serde_json::Value;
4
5use crate::auth::ClerkAuth;
6use crate::backoff::{backoff_delay, retry_after};
7use crate::clock::Clock;
8use crate::consts::{
9    API_MAX_RETRIES, CLIP_PARENT_PATH, FEED_PAGE_DELAY, FEED_PAGE_SIZE, FEED_V2_PATH,
10    IDS_PER_REQUEST, MAX_PAGES, PLAYLIST_ME_PATH, PLAYLIST_PATH, SUNO_API_BASE_URL,
11};
12use crate::error::{Error, Result};
13use crate::http::{Http, HttpRequest, Method};
14use crate::model::Clip;
15
16const EXCLUDED_TASKS: [&str; 2] = ["infill", "fixed_infill"];
17const EXCLUDED_TYPES: [&str; 1] = ["rendered_context_window"];
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    /// Stops early once `limit` clips are collected. Paging is hard-capped at
60    /// [`MAX_PAGES`] so a runaway `has_more` can never loop forever.
61    ///
62    /// Returns the clips paired with a `complete` flag that is `true` only when
63    /// paging ended because the server reported `has_more == false` (the feed
64    /// fully drained). A `limit` stop, or exhausting [`MAX_PAGES`] while
65    /// `has_more` is still set, yields `false` so the caller can refuse to treat
66    /// a truncated listing as authoritative for deletion.
67    pub async fn list_clips(
68        &mut self,
69        http: &impl Http,
70        liked: bool,
71        limit: Option<usize>,
72    ) -> Result<(Vec<Clip>, bool)> {
73        let mut clips = Vec::new();
74        let suffix = if liked { "&is_liked=true" } else { "" };
75        let mut complete = false;
76        for page in 0..MAX_PAGES {
77            if page > 0 {
78                self.clock.sleep(FEED_PAGE_DELAY).await;
79            }
80            let path = format!("{FEED_V2_PATH}?page={page}&page_size={FEED_PAGE_SIZE}{suffix}");
81            let body = self.api_get_retrying(http, &path).await?;
82            let (page_clips, has_more) = parse_feed(&body)?;
83            clips.extend(page_clips);
84            if !has_more {
85                complete = true;
86                break;
87            }
88            if limit.is_some_and(|n| clips.len() >= n) {
89                break;
90            }
91        }
92        if let Some(n) = limit {
93            clips.truncate(n);
94        }
95        Ok((clips, complete))
96    }
97
98    /// Fetch one clip by ID.
99    ///
100    /// Tries the dedicated `/api/clip/{id}` endpoint first, then falls back to
101    /// scanning the library feed, since that endpoint's exact shape is not yet
102    /// confirmed against the live API.
103    pub async fn get_clip(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
104        if let Some(clip) = self.try_get_clip(http, id).await? {
105            return Ok(clip);
106        }
107        self.find_in_feed(http, id).await
108    }
109
110    /// Ask Suno to render a clip to lossless WAV (server-side, asynchronous).
111    pub async fn request_wav(&mut self, http: &impl Http, id: &str) -> Result<()> {
112        let path = format!("/api/gen/{id}/convert_wav/");
113        self.api_request(http, Method::Post, &path).await?;
114        Ok(())
115    }
116
117    /// Read the rendered WAV URL for a clip, or `None` while it is not ready.
118    pub async fn wav_url(&mut self, http: &impl Http, id: &str) -> Result<Option<String>> {
119        let path = format!("/api/gen/{id}/wav_file/");
120        let body = self.api_get(http, &path).await?;
121        let data: Value = serde_json::from_slice(&body)
122            .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
123        Ok(data
124            .get("wav_file_url")
125            .and_then(Value::as_str)
126            .filter(|url| !url.is_empty())
127            .map(str::to_string))
128    }
129
130    /// Fetch specific clips by id through the feed's `?ids=` filter.
131    ///
132    /// Used by lineage resolution to gap-fill ancestors that are absent from a
133    /// normal listing, including trashed ones. Unlike
134    /// [`list_clips`](Self::list_clips), no `keep_clip` filtering is applied: an
135    /// ancestor may itself be an infill or context-window artefact that the
136    /// lineage walk must still traverse. Clips returned here are ancestors for
137    /// resolution only and must never be treated as download candidates. Ids are
138    /// chunked so a long list cannot build an over-long URL.
139    pub async fn get_clips_by_ids(&mut self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
140        let mut clips = Vec::new();
141        for chunk in ids.chunks(IDS_PER_REQUEST) {
142            if chunk.is_empty() {
143                continue;
144            }
145            let joined = chunk.join(",");
146            let path = format!("{FEED_V2_PATH}?ids={joined}");
147            let body = self.api_get_retrying(http, &path).await?;
148            clips.extend(map_all_clips(&body)?);
149        }
150        Ok(clips)
151    }
152
153    /// Fetch a clip's immediate parent via the dedicated parent endpoint.
154    ///
155    /// Returns the parent clip, or `None` when the clip is a root (no parent) or
156    /// the endpoint yields no clip. Lineage resolution uses this as a fallback
157    /// when a missing ancestor cannot be retrieved by id. Only a `404` (the clip
158    /// has no parent) maps to `None`; any other failure, including a transient
159    /// `5xx`, propagates as an error rather than being mistaken for a root.
160    pub async fn get_clip_parent(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
161        let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
162        match self.api_get_retrying(http, &path).await {
163            Ok(body) => Ok(parse_clip(&body)),
164            Err(Error::NotFound(_)) => Ok(None),
165            Err(err) => Err(err),
166        }
167    }
168
169    /// List the account's own playlists, paging `/api/playlist/me`.
170    ///
171    /// Trashed and share-list playlists are excluded by query, so the result is
172    /// the account's authoritative own set. Paging stops on the first empty page
173    /// and is hard-capped at [`MAX_PAGES`] so a server that ignores the page
174    /// parameter cannot loop forever. Only entries with a non-empty id are kept.
175    ///
176    /// A hard failure propagates as an error; the caller treats that as "the
177    /// playlist listing did not fully enumerate" and refuses every playlist
178    /// deletion this run, so a dropped fetch can never remove a `.m3u8`.
179    pub async fn get_playlists(&mut self, http: &impl Http) -> Result<Vec<Playlist>> {
180        let mut playlists = Vec::new();
181        for page in 1..=MAX_PAGES {
182            if page > 1 {
183                self.clock.sleep(FEED_PAGE_DELAY).await;
184            }
185            let path =
186                format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
187            let body = self.api_get_retrying(http, &path).await?;
188            let page_playlists = parse_playlists(&body)?;
189            if page_playlists.is_empty() {
190                break;
191            }
192            playlists.extend(page_playlists);
193        }
194        Ok(playlists)
195    }
196
197    /// Fetch one playlist's clips in Suno order via `/api/playlist/{id}/`.
198    ///
199    /// The response's `playlist_clips[]` is already ordered and trashed members
200    /// are excluded by Suno, so the order is preserved exactly and no `keep_clip`
201    /// filtering is applied — a playlist may legitimately contain any clip. Each
202    /// entry's `clip` object is mapped (falling back to the entry itself), and
203    /// only clips with a non-empty id are kept.
204    pub async fn get_playlist_clips(&mut self, http: &impl Http, id: &str) -> Result<Vec<Clip>> {
205        let path = format!("{PLAYLIST_PATH}{id}/");
206        let body = self.api_get_retrying(http, &path).await?;
207        parse_playlist_clips(&body)
208    }
209
210    /// Try the dedicated clip endpoint, returning `None` when it is missing or
211    /// returns a body that does not yield the requested clip.
212    async fn try_get_clip(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
213        let path = format!("/api/clip/{id}");
214        match self.api_get_retrying(http, &path).await {
215            Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
216            Err(Error::NotFound(_)) => Ok(None),
217            Err(err) => Err(err),
218        }
219    }
220
221    /// Locate a clip by scanning the library feed.
222    async fn find_in_feed(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
223        let (clips, _complete) = self.list_clips(http, false, None).await?;
224        clips
225            .into_iter()
226            .find(|clip| clip.id == id)
227            .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
228    }
229
230    /// Perform an authenticated GET, refreshing the JWT once on a 401/403.
231    async fn api_get(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
232        self.api_request(http, Method::Get, path).await
233    }
234
235    /// Like [`api_get`](Self::api_get) but rides through Suno's rate limiter,
236    /// backing off through the [`Clock`] on a `429` (honouring `Retry-After`
237    /// when present) or a transient connection failure, up to
238    /// [`API_MAX_RETRIES`] times.
239    ///
240    /// The WAV render flow deliberately keeps to the plain [`api_get`](Self::api_get):
241    /// the executor owns that retry so its budget and poll interval stay in one
242    /// place. Library, playlist, and lineage reads use this so a full-library
243    /// walk is not aborted by a single throttled page.
244    async fn api_get_retrying(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
245        let mut retries = 0;
246        loop {
247            match self.api_get(http, path).await {
248                Ok(body) => return Ok(body),
249                Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
250                    self.clock.sleep(backoff_delay(retries, retry_after)).await;
251                    retries += 1;
252                }
253                Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
254                    self.clock.sleep(backoff_delay(retries, None)).await;
255                    retries += 1;
256                }
257                Err(err) => return Err(err),
258            }
259        }
260    }
261
262    /// Perform an authenticated request, refreshing the JWT once on a 401/403.
263    async fn api_request(
264        &mut self,
265        http: &impl Http,
266        method: Method,
267        path: &str,
268    ) -> Result<Vec<u8>> {
269        let url = format!("{SUNO_API_BASE_URL}{path}");
270        let mut auth_refreshed = false;
271        loop {
272            let jwt = self.auth.ensure_jwt(http).await?;
273            let request = HttpRequest {
274                method,
275                url: url.clone(),
276                headers: vec![("Authorization".to_string(), format!("Bearer {jwt}"))],
277            };
278            let response = http
279                .send(request)
280                .await
281                .map_err(|err| Error::Connection(err.to_string()))?;
282            match response.status {
283                200..=299 => return Ok(response.body),
284                401 | 403 if !auth_refreshed => {
285                    self.auth.invalidate_jwt();
286                    auth_refreshed = true;
287                }
288                401 | 403 => {
289                    return Err(Error::Auth(format!(
290                        "Suno API auth failed with status {}",
291                        response.status
292                    )));
293                }
294                429 => {
295                    return Err(Error::RateLimited {
296                        retry_after: retry_after(&response),
297                    });
298                }
299                404 => {
300                    return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
301                }
302                status => {
303                    let preview: String = String::from_utf8_lossy(&response.body)
304                        .chars()
305                        .take(200)
306                        .collect();
307                    return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
308                }
309            }
310        }
311    }
312}
313
314/// Parse a single-clip response body, accepting either a bare clip object or a
315/// `{"clip": {...}}` wrapper. Returns `None` when no clip id is present.
316fn parse_clip(body: &[u8]) -> Option<Clip> {
317    let data: Value = serde_json::from_slice(body).ok()?;
318    let raw = data
319        .get("clip")
320        .filter(|value| value.is_object())
321        .unwrap_or(&data);
322    let has_id = raw
323        .get("id")
324        .and_then(Value::as_str)
325        .is_some_and(|id| !id.is_empty());
326    has_id.then(|| Clip::from_json(raw))
327}
328
329/// Parse a feed page body into the kept clips and the `has_more` flag.
330fn parse_feed(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
331    let data: Value = serde_json::from_slice(body)
332        .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
333    let Some(object) = data.as_object() else {
334        return Ok((Vec::new(), false));
335    };
336    let clips = object
337        .get("clips")
338        .and_then(Value::as_array)
339        .map(|raw| {
340            raw.iter()
341                .filter(|clip| keep_clip(clip))
342                .map(Clip::from_json)
343                .collect()
344        })
345        .unwrap_or_default();
346    let has_more = object
347        .get("has_more")
348        .and_then(Value::as_bool)
349        .unwrap_or(false);
350    Ok((clips, has_more))
351}
352
353/// Map every clip in a feed-shaped body, skipping the `keep_clip` filter.
354///
355/// Accepts either a `{"clips": [...]}` wrapper or a bare array, dropping only
356/// elements that carry no id. Used for id-filtered gap-fill fetches, which must
357/// preserve trashed and artefact clips so the lineage walk can traverse them.
358fn map_all_clips(body: &[u8]) -> Result<Vec<Clip>> {
359    let data: Value = serde_json::from_slice(body)
360        .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
361    let items: &[Value] = match &data {
362        Value::Array(items) => items.as_slice(),
363        Value::Object(_) => data
364            .get("clips")
365            .and_then(Value::as_array)
366            .map_or(&[][..], |arr| arr.as_slice()),
367        _ => &[],
368    };
369    Ok(items
370        .iter()
371        .map(Clip::from_json)
372        .filter(|clip| !clip.id.is_empty())
373        .collect())
374}
375
376/// Parse a `/api/playlist/me` page into playlists, dropping entries with no id.
377fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
378    let data: Value = serde_json::from_slice(body)
379        .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
380    Ok(data
381        .get("playlists")
382        .and_then(Value::as_array)
383        .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
384        .unwrap_or_default())
385}
386
387/// Map one raw `/api/playlist/me` entry, or `None` when it carries no id.
388///
389/// `num_total_results` is the playlist's member count; a missing name defaults
390/// to `Untitled` (matching the clip mapping) so the file name is never empty.
391fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
392    let id = raw
393        .get("id")
394        .and_then(Value::as_str)
395        .filter(|id| !id.is_empty())?
396        .to_string();
397    let name = match raw.get("name") {
398        Some(Value::String(name)) if !name.is_empty() => name.clone(),
399        _ => "Untitled".to_string(),
400    };
401    let num_clips = raw
402        .get("num_total_results")
403        .and_then(Value::as_u64)
404        .unwrap_or(0);
405    Some(Playlist {
406        id,
407        name,
408        num_clips,
409    })
410}
411
412/// Parse a `/api/playlist/{id}/` body into its ordered member clips.
413///
414/// Each `playlist_clips[]` entry wraps the clip under `clip`; the wrapper is
415/// unwrapped (falling back to the entry itself), order is preserved exactly, and
416/// only clips with a non-empty id survive. No `keep_clip` filter is applied: a
417/// playlist may hold any clip, and members absent from the local library are
418/// reconciled as comment lines by the caller, not dropped here.
419fn parse_playlist_clips(body: &[u8]) -> Result<Vec<Clip>> {
420    let data: Value = serde_json::from_slice(body)
421        .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
422    Ok(data
423        .get("playlist_clips")
424        .and_then(Value::as_array)
425        .map(|raw| {
426            raw.iter()
427                .map(|entry| {
428                    let clip = entry
429                        .get("clip")
430                        .filter(|value| value.is_object())
431                        .unwrap_or(entry);
432                    Clip::from_json(clip)
433                })
434                .filter(|clip| !clip.id.is_empty())
435                .collect()
436        })
437        .unwrap_or_default())
438}
439
440/// Keep only finished clips that are not infills or context-window artefacts.
441fn keep_clip(raw: &Value) -> bool {
442    if raw.get("status").and_then(Value::as_str) != Some("complete") {
443        return false;
444    }
445    let metadata = raw.get("metadata");
446    let clip_type = metadata.and_then(|m| m.get("type")).and_then(Value::as_str);
447    if clip_type.is_some_and(|t| EXCLUDED_TYPES.contains(&t)) {
448        return false;
449    }
450    let task = metadata.and_then(|m| m.get("task")).and_then(Value::as_str);
451    !task.is_some_and(|t| EXCLUDED_TASKS.contains(&t))
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
458    use std::time::Duration;
459
460    fn feed_body() -> String {
461        serde_json::json!({
462            "has_more": false,
463            "clips": [
464                {
465                    "id": "a", "title": "Song A", "status": "complete",
466                    "audio_url": "https://cdn1.suno.ai/a.mp3",
467                    "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
468                },
469                {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
470                {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
471                {
472                    "id": "d", "title": "Context", "status": "complete",
473                    "metadata": {"type": "rendered_context_window"}
474                }
475            ]
476        })
477        .to_string()
478    }
479
480    #[test]
481    fn parse_feed_filters_and_maps() {
482        let (clips, has_more) = parse_feed(feed_body().as_bytes()).unwrap();
483        assert!(!has_more);
484        assert_eq!(clips.len(), 1);
485        assert_eq!(clips[0].id, "a");
486        assert_eq!(clips[0].tags, "rock");
487        assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
488    }
489
490    #[test]
491    fn audiopipe_url_is_rewritten_to_cdn() {
492        let raw =
493            serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
494        assert_eq!(
495            Clip::from_json(&raw).audio_url,
496            "https://cdn1.suno.ai/x.mp3"
497        );
498    }
499
500    #[test]
501    fn list_clips_authenticates_then_reads_the_feed() {
502        let client_body = serde_json::json!({
503            "response": {
504                "last_active_session_id": "s",
505                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
506            }
507        })
508        .to_string();
509        let http = MockHttp::new(vec![
510            Rule::new(
511                "/v1/client/sessions/",
512                200,
513                r#"{"jwt": "a.b.c"}"#.to_string(),
514            ),
515            Rule::new("/v1/client", 200, client_body),
516            Rule::new("/api/feed/v2", 200, feed_body()),
517        ]);
518
519        let mut auth = ClerkAuth::new("eyJtoken");
520        pollster::block_on(auth.authenticate(&http)).unwrap();
521        let mut client = SunoClient::new(auth, RecordingClock::new());
522        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
523        assert_eq!(clips.len(), 1);
524        assert_eq!(clips[0].id, "a");
525        assert!(complete);
526    }
527
528    #[test]
529    fn list_clips_reports_incomplete_when_paging_is_capped() {
530        let mut rules = auth_rules();
531        rules.push(Rule::new(
532            "/api/feed/v2",
533            200,
534            serde_json::json!({
535                "has_more": true,
536                "clips": [{
537                    "id": "a", "title": "Song A", "status": "complete",
538                    "audio_url": "https://cdn1.suno.ai/a.mp3",
539                    "metadata": {"type": "gen"}
540                }]
541            })
542            .to_string(),
543        ));
544        let http = MockHttp::new(rules);
545        let mut client = authed_client(&http);
546
547        let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
548        assert!(!complete);
549    }
550
551    fn auth_rules() -> Vec<Rule> {
552        let client_body = serde_json::json!({
553            "response": {
554                "last_active_session_id": "s",
555                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
556            }
557        })
558        .to_string();
559        vec![
560            Rule::new(
561                "/v1/client/sessions/",
562                200,
563                r#"{"jwt": "a.b.c"}"#.to_string(),
564            ),
565            Rule::new("/v1/client", 200, client_body),
566        ]
567    }
568
569    fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
570        let mut auth = ClerkAuth::new("eyJtoken");
571        pollster::block_on(auth.authenticate(http)).unwrap();
572        SunoClient::new(auth, RecordingClock::new())
573    }
574
575    fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
576        let mut auth = ClerkAuth::new("eyJtoken");
577        pollster::block_on(auth.authenticate(http)).unwrap();
578        SunoClient::new(auth, clock)
579    }
580
581    fn one_clip_page(id: &str, has_more: bool) -> String {
582        serde_json::json!({
583            "has_more": has_more,
584            "clips": [{
585                "id": id, "title": "Song", "status": "complete",
586                "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
587                "metadata": {"type": "gen"}
588            }]
589        })
590        .to_string()
591    }
592
593    #[test]
594    fn list_clips_retries_a_rate_limited_page() {
595        let http = ScriptedHttp::new().with_auth().route_seq(
596            "/api/feed/v2",
597            vec![Reply::status(429), Reply::json(&feed_body())],
598        );
599        let clock = RecordingClock::new();
600        let mut client = scripted_client(&http, clock.clone());
601
602        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
603        assert_eq!(clips.len(), 1);
604        assert!(complete);
605        // The throttled page was retried once, waiting out one base backoff.
606        assert_eq!(http.count("/api/feed/v2"), 2);
607        assert_eq!(clock.sleeps(), vec![Duration::from_secs(1)]);
608    }
609
610    #[test]
611    fn list_clips_honours_retry_after_on_a_throttled_page() {
612        let http = ScriptedHttp::new().with_auth().route_seq(
613            "/api/feed/v2",
614            vec![
615                Reply::status(429).with_retry_after(7),
616                Reply::json(&feed_body()),
617            ],
618        );
619        let clock = RecordingClock::new();
620        let mut client = scripted_client(&http, clock.clone());
621
622        let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
623        assert_eq!(clips.len(), 1);
624        // The server's Retry-After floors the backoff above the 1s base.
625        assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
626    }
627
628    #[test]
629    fn list_clips_paces_between_pages() {
630        let http = ScriptedHttp::new().with_auth().route_seq(
631            "/api/feed/v2",
632            vec![
633                Reply::json(&one_clip_page("a", true)),
634                Reply::json(&one_clip_page("e", false)),
635            ],
636        );
637        let clock = RecordingClock::new();
638        let mut client = scripted_client(&http, clock.clone());
639
640        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
641        assert!(complete);
642        assert_eq!(clips.len(), 2);
643        assert_eq!(http.count("/api/feed/v2"), 2);
644        // One inter-page pace was waited out before fetching the second page.
645        assert_eq!(clock.sleeps(), vec![crate::consts::FEED_PAGE_DELAY]);
646    }
647
648    #[test]
649    fn list_clips_gives_up_after_max_retries() {
650        let http = ScriptedHttp::new()
651            .with_auth()
652            .route("/api/feed/v2", Reply::status(429));
653        let clock = RecordingClock::new();
654        let mut client = scripted_client(&http, clock.clone());
655
656        let result = pollster::block_on(client.list_clips(&http, false, None));
657        assert!(matches!(result, Err(Error::RateLimited { .. })));
658        let budget = crate::consts::API_MAX_RETRIES as usize;
659        assert_eq!(clock.sleeps().len(), budget);
660        assert_eq!(http.count("/api/feed/v2"), budget + 1);
661    }
662
663    #[test]
664    fn parse_clip_accepts_bare_and_wrapped_shapes() {
665        let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
666        assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
667
668        let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
669        assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
670
671        let missing = serde_json::json!({"detail": "not found"}).to_string();
672        assert!(parse_clip(missing.as_bytes()).is_none());
673    }
674
675    #[test]
676    fn get_clip_uses_the_dedicated_endpoint() {
677        let clip_body = serde_json::json!({
678            "id": "z", "title": "Zed", "status": "complete",
679            "audio_url": "https://cdn1.suno.ai/z.mp3",
680            "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
681        })
682        .to_string();
683        let mut rules = auth_rules();
684        rules.push(Rule::new("/api/clip/", 200, clip_body));
685        let http = MockHttp::new(rules);
686        let mut client = authed_client(&http);
687
688        let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
689        assert_eq!(clip.id, "z");
690        assert_eq!(clip.title, "Zed");
691        assert_eq!(clip.tags, "jazz");
692    }
693
694    #[test]
695    fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
696        let mut rules = auth_rules();
697        rules.push(Rule::new(
698            "/api/clip/",
699            404,
700            r#"{"detail": "not found"}"#.to_string(),
701        ));
702        rules.push(Rule::new("/api/feed/v2", 200, feed_body()));
703        let http = MockHttp::new(rules);
704        let mut client = authed_client(&http);
705
706        let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
707        assert_eq!(clip.id, "a");
708        assert_eq!(clip.tags, "rock");
709    }
710
711    #[test]
712    fn request_wav_accepts_a_2xx_status() {
713        let mut rules = auth_rules();
714        rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
715        let http = MockHttp::new(rules);
716        let mut client = authed_client(&http);
717
718        assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
719    }
720
721    #[test]
722    fn wav_url_reads_the_ready_url() {
723        let mut rules = auth_rules();
724        rules.push(Rule::new(
725            "/wav_file/",
726            200,
727            r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
728        ));
729        let http = MockHttp::new(rules);
730        let mut client = authed_client(&http);
731
732        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
733        assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
734    }
735
736    #[test]
737    fn wav_url_is_none_until_the_render_is_ready() {
738        let mut rules = auth_rules();
739        rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
740        let http = MockHttp::new(rules);
741        let mut client = authed_client(&http);
742
743        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
744        assert_eq!(url, None);
745    }
746
747    #[test]
748    fn get_clips_by_ids_uses_the_ids_filter_and_keeps_all_clips() {
749        // The `?ids=` gap-fill path must not apply the listing's `keep_clip`
750        // filter: an infill ancestor and an upload root both survive.
751        let feed = serde_json::json!({
752            "clips": [
753                {
754                    "id": "p1", "title": "Infill Ancestor", "status": "complete",
755                    "metadata": {"type": "gen", "task": "infill"}
756                },
757                {
758                    "id": "p2", "title": "Uploaded Root", "status": "complete",
759                    "metadata": {"type": "upload"}
760                }
761            ]
762        })
763        .to_string();
764        let mut rules = auth_rules();
765        // The exact substring also asserts the ids are comma-joined into the URL.
766        rules.push(Rule::new("/api/feed/v2/?ids=p1,p2", 200, feed));
767        let http = MockHttp::new(rules);
768        let mut client = authed_client(&http);
769
770        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"])).unwrap();
771        assert_eq!(
772            clips.len(),
773            2,
774            "infill and upload ancestors must not be filtered"
775        );
776        assert_eq!(clips[0].id, "p1");
777        assert_eq!(clips[1].id, "p2");
778    }
779
780    #[test]
781    fn get_clips_by_ids_accepts_a_bare_array_body() {
782        let body = serde_json::json!([
783            {"id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}}
784        ])
785        .to_string();
786        let mut rules = auth_rules();
787        rules.push(Rule::new("/api/feed/v2/?ids=only", 200, body));
788        let http = MockHttp::new(rules);
789        let mut client = authed_client(&http);
790
791        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["only"])).unwrap();
792        assert_eq!(clips.len(), 1);
793        assert_eq!(clips[0].id, "only");
794    }
795
796    #[test]
797    fn get_clip_parent_reads_the_parent_clip() {
798        let parent = serde_json::json!({
799            "id": "par", "title": "Ancestor", "status": "complete",
800            "metadata": {"type": "gen"}
801        })
802        .to_string();
803        let mut rules = auth_rules();
804        rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
805        let http = MockHttp::new(rules);
806        let mut client = authed_client(&http);
807
808        let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
809        assert_eq!(clip.unwrap().id, "par");
810    }
811
812    #[test]
813    fn get_clip_parent_is_none_for_a_root() {
814        let mut rules = auth_rules();
815        rules.push(Rule::new(
816            "/api/clips/parent",
817            404,
818            r#"{"detail": "no parent"}"#.to_string(),
819        ));
820        let http = MockHttp::new(rules);
821        let mut client = authed_client(&http);
822
823        let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
824        assert!(clip.is_none());
825    }
826
827    #[test]
828    fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
829        // A transient 5xx must never be mistaken for "this clip is a root":
830        // folding it into Ok(None) would fabricate a wrong external root and let
831        // a blip rewrite lineage (HARDENING H3). Only a real 404 means no parent.
832        for status in [500u16, 503] {
833            let mut rules = auth_rules();
834            rules.push(Rule::new(
835                "/api/clips/parent",
836                status,
837                r#"{"detail": "server error"}"#.to_string(),
838            ));
839            let http = MockHttp::new(rules);
840            let mut client = authed_client(&http);
841
842            let result = pollster::block_on(client.get_clip_parent(&http, "child"));
843            assert!(
844                matches!(result, Err(Error::Api(_))),
845                "status {status} must propagate as an error, not Ok(None)"
846            );
847        }
848    }
849
850    #[test]
851    fn get_playlists_maps_entries_and_skips_missing_ids() {
852        let page1 = serde_json::json!({
853            "playlists": [
854                {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
855                {"id": "", "name": "No Id", "num_total_results": 3},
856                {"name": "Also No Id"}
857            ]
858        })
859        .to_string();
860        let mut rules = auth_rules();
861        // Page 1 returns entries; page 2 is empty, ending pagination.
862        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
863        rules.push(Rule::new(
864            "/api/playlist/me?page=2",
865            200,
866            r#"{"playlists": []}"#.to_string(),
867        ));
868        let http = MockHttp::new(rules);
869        let mut client = authed_client(&http);
870
871        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
872        assert_eq!(playlists.len(), 1, "entries without an id are dropped");
873        assert_eq!(
874            playlists[0],
875            Playlist {
876                id: "pl1".to_owned(),
877                name: "Road Trip".to_owned(),
878                num_clips: 12,
879            }
880        );
881    }
882
883    #[test]
884    fn get_playlists_defaults_a_missing_name_to_untitled() {
885        let page1 = serde_json::json!({
886            "playlists": [{"id": "pl9", "num_total_results": 1}]
887        })
888        .to_string();
889        let mut rules = auth_rules();
890        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
891        rules.push(Rule::new(
892            "/api/playlist/me?page=2",
893            200,
894            r#"{"playlists": []}"#.to_string(),
895        ));
896        let http = MockHttp::new(rules);
897        let mut client = authed_client(&http);
898
899        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
900        assert_eq!(playlists[0].name, "Untitled");
901    }
902
903    #[test]
904    fn get_playlist_clips_preserves_order_and_unwraps_clip() {
905        // Members arrive wrapped under `clip`, in playlist order, already
906        // non-trashed. Order is preserved and no keep_clip filter is applied.
907        let body = serde_json::json!({
908            "playlist_clips": [
909                {"clip": {
910                    "id": "second", "title": "Second", "status": "complete",
911                    "metadata": {"duration": 60.0, "type": "gen"}
912                }},
913                {"clip": {
914                    "id": "first", "title": "First", "status": "complete",
915                    "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
916                }}
917            ]
918        })
919        .to_string();
920        let mut rules = auth_rules();
921        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
922        let http = MockHttp::new(rules);
923        let mut client = authed_client(&http);
924
925        let clips = pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
926        assert_eq!(clips.len(), 2, "an infill member is not filtered out");
927        assert_eq!(clips[0].id, "second");
928        assert_eq!(clips[1].id, "first");
929    }
930
931    #[test]
932    fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
933        let mut rules = auth_rules();
934        rules.push(Rule::new(
935            "/api/playlist/empty/",
936            200,
937            r#"{"playlist_clips": []}"#.to_string(),
938        ));
939        let http = MockHttp::new(rules);
940        let mut client = authed_client(&http);
941
942        let clips = pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
943        assert!(clips.is_empty());
944    }
945}