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