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