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