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