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;
4use std::sync::Mutex;
5use std::time::Instant;
6
7use futures_util::stream::{self, StreamExt};
8use serde_json::Value;
9
10use crate::auth::ClerkAuth;
11use crate::backoff::{backoff_delay, retry_after};
12use crate::clock::Clock;
13use crate::consts::{
14    API_MAX_RETRIES, BILLING_INFO_PATH, CLIP_PARENT_PATH, FEED_INITIAL_RATE, FEED_PAGE_SIZE,
15    FEED_V3_PATH, MAX_PAGES, PLAYLIST_ME_PATH, PLAYLIST_PATH, SUNO_API_BASE_URL,
16};
17use crate::error::{Error, Result};
18use crate::http::{Http, HttpRequest, Method};
19use crate::is_downloadable;
20use crate::limiter::{AdaptiveLimiter, retry_after_delay};
21use crate::lyrics::AlignedLyrics;
22use crate::model::Clip;
23
24/// One of the account's own playlists, as listed by `/api/playlist/me`.
25///
26/// Carries only what playlist reconciliation needs: the stable id (the state
27/// key), the display name (drives the `.m3u8` file name and `#PLAYLIST` line),
28/// and the member count for reporting. The ordered members are fetched
29/// separately with [`SunoClient::get_playlist_clips`].
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Playlist {
32    /// The playlist's stable Suno id.
33    pub id: String,
34    /// The playlist's display name.
35    pub name: String,
36    /// The number of clips Suno reports in the playlist.
37    pub num_clips: u64,
38}
39
40/// The authenticated account's current remaining credit balance.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct BillingInfo {
43    /// Credits remaining in the current billing state.
44    pub total_credits_left: u64,
45}
46
47/// One separated stem of a clip, as listed by the free, read-only stems
48/// endpoint.
49///
50/// A stem is itself a full clip object: the listing returns the same shape as
51/// the library feed, so each stem carries its own clip `id`, a `title` whose
52/// trailing parenthetical is the stem label (e.g. `"My Song (Vocals)"`), a
53/// `status`, and a public `audio_url` on `cdn1.suno.ai` that downloads free and
54/// unauthenticated. Listing and downloading stems never spends credits or
55/// triggers separation.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Stem {
58    /// The stem's own server clip id. Used both as the stable per-stem key and
59    /// to render the stem's lossless WAV through the free `convert_wav` flow.
60    pub id: String,
61    /// The stem label, taken from the trailing parenthetical of the stem clip's
62    /// title (e.g. `Vocals`, `Backing Vocals`, `Drums`). May be blank when the
63    /// title has no parenthetical, so it is never used alone as a key or name.
64    pub label: String,
65    /// The public CDN MP3 URL the stem downloads from (a plain GET; free).
66    pub url: String,
67}
68
69/// A client for the Suno library API, owning the account's [`ClerkAuth`].
70///
71/// The [`Clock`] is held so [`api_request`](Self::api_request) can back off
72/// through the port on a `429` or transient failure — the engine still sleeps
73/// nowhere itself. The [`AdaptiveLimiter`] paces reactively: an unthrottled
74/// listing waits nowhere, and only after a `429` does it space requests out,
75/// halving the rate and ramping it back after a run of clean successes so pacing
76/// tracks Suno's real limit rather than a fixed constant.
77pub struct SunoClient<C> {
78    auth: ClerkAuth,
79    clock: C,
80    limiter: Mutex<AdaptiveLimiter>,
81}
82
83impl<C: Clock> SunoClient<C> {
84    /// Create a client from a fresh or already-authenticated [`ClerkAuth`].
85    pub fn new(auth: ClerkAuth, clock: C) -> Self {
86        Self {
87            auth,
88            clock,
89            limiter: Mutex::new(AdaptiveLimiter::new(FEED_INITIAL_RATE)),
90        }
91    }
92
93    /// Borrow the underlying authenticator.
94    pub fn auth(&self) -> &ClerkAuth {
95        &self.auth
96    }
97
98    /// The adaptive limiter's current requests-per-second rate, for tests that
99    /// assert the limiter still records success and `429` correctly (including
100    /// under concurrent WAV-render calls serialised through the executor).
101    #[cfg(test)]
102    pub(crate) fn limiter_rate(&self) -> f64 {
103        self.limiter.lock().unwrap().rate()
104    }
105
106    /// List clips across the whole library, or only liked clips.
107    ///
108    /// Walks the cursor-paginated `POST /api/feed/v3` feed, following
109    /// `next_cursor` until the server reports the end. Once `limit` clips have
110    /// been collected it stops at the next page boundary and truncates to
111    /// `limit`. Paging is hard-capped at [`MAX_PAGES`] so a runaway
112    /// `has_more` can never loop forever. When `liked` is set the feed filter
113    /// scopes to liked clips (`liked: "True"`).
114    ///
115    /// Returns the clips paired with a `complete` flag that is `true` only when
116    /// paging ended because the server reported `has_more == false` (the feed
117    /// fully drained). A missing `has_more`, a `has_more == true` page with no
118    /// usable `next_cursor`, a `limit` stop, exhausting [`MAX_PAGES`], or any
119    /// transport error all yield `false` (or propagate) so the caller can refuse
120    /// to treat a truncated listing as authoritative for deletion.
121    pub async fn list_clips(
122        &self,
123        http: &impl Http,
124        liked: bool,
125        limit: Option<usize>,
126    ) -> Result<(Vec<Clip>, bool)> {
127        let mut clips = Vec::new();
128        let mut cursor: Option<String> = None;
129        let mut complete = false;
130        for _ in 0..MAX_PAGES {
131            let body = feed_v3_body(liked, cursor.as_deref());
132            let response = self
133                .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
134                .await?;
135            let (page_clips, has_more, next_cursor) = parse_feed_v3(&response)?;
136            clips.extend(page_clips);
137            match has_more {
138                Some(false) => {
139                    complete = true;
140                    break;
141                }
142                Some(true) => match next_cursor {
143                    Some(next) => cursor = Some(next),
144                    None => break,
145                },
146                None => break,
147            }
148            if limit.is_some_and(|n| clips.len() >= n) {
149                break;
150            }
151        }
152        if let Some(n) = limit {
153            clips.truncate(n);
154        }
155        Ok((clips, complete))
156    }
157
158    /// Fetch one clip by ID.
159    ///
160    /// Tries the dedicated `/api/clip/{id}` endpoint first, then falls back to
161    /// scanning the library feed, since that endpoint's exact shape is not yet
162    /// confirmed against the live API.
163    pub async fn get_clip(&self, http: &impl Http, id: &str) -> Result<Clip> {
164        if let Some(clip) = self.try_get_clip(http, id).await? {
165            return Ok(clip);
166        }
167        self.find_in_feed(http, id).await
168    }
169
170    /// Ask Suno to render a clip to lossless WAV (server-side, asynchronous).
171    pub async fn request_wav(&self, http: &impl Http, id: &str) -> Result<()> {
172        let path = format!("/api/gen/{id}/convert_wav/");
173        self.api_request(http, Method::Post, &path, Vec::new())
174            .await?;
175        Ok(())
176    }
177
178    /// Read the rendered WAV URL for a clip, or `None` while it is not ready.
179    pub async fn wav_url(&self, http: &impl Http, id: &str) -> Result<Option<String>> {
180        let path = format!("/api/gen/{id}/wav_file/");
181        let body = self.api_get(http, &path).await?;
182        let data: Value = serde_json::from_slice(&body)
183            .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
184        Ok(data
185            .get("wav_file_url")
186            .and_then(Value::as_str)
187            .filter(|url| !url.is_empty())
188            .map(str::to_string))
189    }
190
191    /// Fetch a clip's word- and line-level aligned (synced) lyrics.
192    ///
193    /// `GET /api/gen/{id}/aligned_lyrics/v2/` (the trailing slash is required) on
194    /// the studio-api host, authenticated with the same JWT as every other
195    /// library read. The `v2` shape carries both a flat word-level list and a
196    /// line-level list with section labels and nested per-word timing (see
197    /// [`AlignedLyrics`]).
198    ///
199    /// An instrumental or un-alignable clip returns `200` with empty arrays,
200    /// which maps to an empty [`AlignedLyrics`]; a `404` (no alignment for the
201    /// clip) is treated the same way, so an absent endpoint is "no synced
202    /// lyrics" rather than a run failure — the caller then writes no synced
203    /// artefact, exactly as an empty cover URL writes no cover. Rides the
204    /// adaptive rate limiter like the other reads.
205    pub async fn aligned_lyrics(&self, http: &impl Http, id: &str) -> Result<AlignedLyrics> {
206        let path = format!("/api/gen/{id}/aligned_lyrics/v2/");
207        match self.api_get_retrying(http, &path).await {
208            Ok(body) => Ok(AlignedLyrics::from_bytes(&body)),
209            Err(Error::NotFound(_)) => Ok(AlignedLyrics::default()),
210            Err(err) => Err(err),
211        }
212    }
213
214    /// Fetch specific clips by id, one `GET /api/clip/{id}` per id.
215    ///
216    /// Used by lineage resolution to gap-fill ancestors that are absent from a
217    /// normal listing, including trashed ones. The v3 feed has no batch by-id
218    /// filter, so each id is fetched individually; `/api/clip/{id}` returns any
219    /// clip, trashed or artefact, with the full field set. Unlike
220    /// [`list_clips`](Self::list_clips), no downloadability filter is applied: an
221    /// ancestor may itself be an infill or context-window artefact that the
222    /// lineage walk must still traverse. Clips returned here are ancestors for
223    /// resolution only and must never be treated as download candidates. Ids are
224    /// deduplicated in order, and an id that cannot be retrieved (a `404`) is
225    /// skipped so the caller can fall back to the parent endpoint. Requests are
226    /// issued with bounded concurrency, preserving the de-duplicated input order.
227    pub async fn get_clips_by_ids(
228        &self,
229        http: &impl Http,
230        ids: &[&str],
231        concurrency: usize,
232    ) -> Result<Vec<Clip>> {
233        let mut seen: BTreeSet<&str> = BTreeSet::new();
234        let ordered: Vec<&str> = ids
235            .iter()
236            .copied()
237            .filter(|id| !id.is_empty() && seen.insert(id))
238            .collect();
239        let limit = concurrency.max(1);
240        let fetched = stream::iter(ordered.iter().copied())
241            .map(|id| async move {
242                let path = format!("/api/clip/{id}");
243                match self.api_get_retrying(http, &path).await {
244                    Ok(body) => Ok(parse_clip(&body)),
245                    Err(Error::NotFound(_)) => Ok(None),
246                    Err(err) => Err(err),
247                }
248            })
249            .buffered(limit)
250            .collect::<Vec<_>>()
251            .await;
252        let mut clips = Vec::new();
253        for item in fetched {
254            let clip = item?;
255            if let Some(clip) = clip {
256                clips.push(clip);
257            }
258        }
259        Ok(clips)
260    }
261
262    /// Fetch a clip's immediate parent via the dedicated parent endpoint.
263    ///
264    /// Returns the parent clip, or `None` when the clip is a root (no parent) or
265    /// the endpoint yields no clip. Lineage resolution uses this as a fallback
266    /// when a missing ancestor cannot be retrieved by id. Only a `404` (the clip
267    /// has no parent) maps to `None`; any other failure, including a transient
268    /// `5xx`, propagates as an error rather than being mistaken for a root.
269    pub async fn get_clip_parent(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
270        let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
271        match self.api_get_retrying(http, &path).await {
272            Ok(body) => Ok(parse_clip(&body)),
273            Err(Error::NotFound(_)) => Ok(None),
274            Err(err) => Err(err),
275        }
276    }
277
278    /// List the account's own playlists, paging `/api/playlist/me`.
279    ///
280    /// Trashed and share-list playlists are excluded by query, so the result is
281    /// the account's authoritative own set. Paging stops on the first empty page
282    /// and is hard-capped at [`MAX_PAGES`] so a server that ignores the page
283    /// parameter cannot loop forever. Only entries with a non-empty id are kept,
284    /// and accumulated entries are de-duplicated by id so a server that ignores
285    /// the page parameter and repeats a body cannot inflate the set.
286    ///
287    /// A hard failure propagates as an error; the caller treats that as "the
288    /// playlist listing did not fully enumerate" and refuses every playlist
289    /// deletion this run, so a dropped fetch can never remove a `.m3u8`.
290    pub async fn get_playlists(&self, http: &impl Http) -> Result<Vec<Playlist>> {
291        let mut playlists = Vec::new();
292        let mut seen = BTreeSet::new();
293        for page in 1..=MAX_PAGES {
294            let path =
295                format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
296            let body = self.api_get_retrying(http, &path).await?;
297            let page_playlists = parse_playlists(&body)?;
298            if page_playlists.is_empty() {
299                break;
300            }
301            for playlist in page_playlists {
302                if seen.insert(playlist.id.clone()) {
303                    playlists.push(playlist);
304                }
305            }
306        }
307        Ok(playlists)
308    }
309
310    /// Fetch one playlist's clips in Suno order via `/api/playlist/{id}/`.
311    ///
312    /// The response's `playlist_clips[]` is already ordered and trashed members
313    /// are excluded by Suno, so the order is preserved exactly and no
314    /// downloadability filter is applied: a playlist may legitimately contain any
315    /// clip. Each entry's `clip` object is mapped (falling back to the entry
316    /// itself), and only clips with a non-empty id are kept.
317    ///
318    /// The returned `bool` is a completeness signal for deletion authority: the
319    /// endpoint reports `num_total_results` (the playlist's full member count)
320    /// alongside `playlist_clips[]`, so `true` means every member came back on
321    /// this single page intact (`num_total_results` present, equal to the raw
322    /// count, and no member dropped for a missing/empty id). A short page, or one
323    /// missing a member's id, returns `false`, so a Mirror playlist area under
324    /// `library = "off"` is never treated as authoritative unless its whole
325    /// member set was seen (D5).
326    pub async fn get_playlist_clips(
327        &self,
328        http: &impl Http,
329        id: &str,
330    ) -> Result<(Vec<Clip>, bool)> {
331        let path = format!("{PLAYLIST_PATH}{id}/");
332        let body = self.api_get_retrying(http, &path).await?;
333        parse_playlist_clips(&body)
334    }
335
336    /// Read the authenticated account's billing information.
337    pub async fn get_billing_info(&self, http: &impl Http) -> Result<BillingInfo> {
338        let body = self.api_get_retrying(http, BILLING_INFO_PATH).await?;
339        parse_billing_info(&body)
340    }
341
342    /// List a clip's already-separated stems (free, read-only).
343    ///
344    /// Uses the live stems shape: first `GET /api/clip/{id}/stems/pages` for the
345    /// page count (`{"pages": N}`), then `GET /api/clip/{id}/stems?page=P` for
346    /// each `P` in `0..N` (the pages are 0-indexed), whose body is
347    /// `{"stems": [<clip>, ...]}` where each stem is a full clip object. Every
348    /// request rides the shared limiter and retry. This endpoint only reads: it
349    /// never spends credits and never triggers separation, so it is safe on the
350    /// bulk mirror path. The caller must only invoke it when the clip's
351    /// `has_stem` is true.
352    ///
353    /// Returns the collected stems paired with a `complete` flag that is `true`
354    /// only when the listing was fully and authoritatively enumerated: the page
355    /// count came back and every one of its pages drained, AFTER at least one
356    /// stem was seen. This encodes the deletion-safety invariant: an empty
357    /// listing (`pages == 0`, or a `400`/`404` on the page-count endpoint, which
358    /// Suno returns for a clip with zero stems), a transport failure, or a
359    /// partial drain (a page error mid-enumeration surfaces as `Err`) all yield a
360    /// non-authoritative result, so the caller KEEPS any existing local stems and
361    /// never reads the absence as "no stems". A clip that declares more than
362    /// [`MAX_PAGES`] pages is likewise a truncated listing and never authoritative.
363    /// A stem is only ever removed from an authoritative (`complete`) listing that
364    /// omits it, or when its owning clip's audio is deleted.
365    pub async fn list_stems(&self, http: &impl Http, clip_id: &str) -> Result<(Vec<Stem>, bool)> {
366        let declared = self.stem_page_count(http, clip_id).await?;
367        // Zero pages (or no page count) is Suno's "this clip has no stems"
368        // answer: indeterminate for deletion, never an authoritative empty.
369        if declared == 0 {
370            return Ok((Vec::new(), false));
371        }
372        let pages = declared.min(MAX_PAGES);
373        let mut stems: Vec<Stem> = Vec::new();
374        for page in 0..pages {
375            // Pages are 0-indexed (0..N-1); note the path has no trailing slash
376            // before the query, distinguishing it from `.../stems/pages`.
377            let path = format!("/api/clip/{clip_id}/stems?page={page}");
378            // A page error mid-enumeration is indeterminate, not a clean end:
379            // surface it so the caller keeps existing stems rather than reading a
380            // partial drain as authoritative and removing stems.
381            let body = self.api_get_retrying(http, &path).await?;
382            stems.extend(parse_stems_page(&body));
383        }
384        dedupe_stems(&mut stems);
385        // Authoritative only when the whole declared page set actually drained
386        // and it held stems: an all-empty listing is never "no stems", and a
387        // clip declaring more than the `MAX_PAGES` cap is a truncated listing,
388        // never authoritative, so its un-fetched stems are kept (mirroring the
389        // feed's `list_clips` cap handling).
390        let complete = !stems.is_empty() && declared <= MAX_PAGES;
391        Ok((stems, complete))
392    }
393
394    /// Read the stems page count for a clip from `GET /api/clip/{id}/stems/pages`
395    /// (`{"pages": N}`).
396    ///
397    /// A clip with no stems answers `400`/`404` here; both mean "no stems" and
398    /// map to `0` (indeterminate, never an authoritative empty set), while any
399    /// other error (a transient `5xx`, a transport failure) propagates so the
400    /// caller treats the stems as unknown and keeps them.
401    async fn stem_page_count(&self, http: &impl Http, clip_id: &str) -> Result<u32> {
402        let path = format!("/api/clip/{clip_id}/stems/pages");
403        match self.api_get_retrying(http, &path).await {
404            Ok(body) => Ok(parse_stem_page_count(&body)),
405            Err(err) if is_invalid_page_error(&err) => Ok(0),
406            Err(Error::NotFound(_)) => Ok(0),
407            Err(err) => Err(err),
408        }
409    }
410
411    /// Try the dedicated clip endpoint, returning `None` when it is missing or
412    /// returns a body that does not yield the requested clip.
413    async fn try_get_clip(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
414        let path = format!("/api/clip/{id}");
415        match self.api_get_retrying(http, &path).await {
416            Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
417            Err(Error::NotFound(_)) => Ok(None),
418            Err(err) => Err(err),
419        }
420    }
421
422    /// Locate a clip by scanning the library feed.
423    async fn find_in_feed(&self, http: &impl Http, id: &str) -> Result<Clip> {
424        let (clips, _complete) = self.list_clips(http, false, None).await?;
425        clips
426            .into_iter()
427            .find(|clip| clip.id == id)
428            .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
429    }
430
431    /// Perform an authenticated GET, refreshing the JWT once on a 401/403.
432    async fn api_get(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
433        self.api_request(http, Method::Get, path, Vec::new()).await
434    }
435
436    /// A retrying GET: [`api_send_retrying`](Self::api_send_retrying) with no body.
437    async fn api_get_retrying(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
438        self.api_send_retrying(http, Method::Get, path, Vec::new())
439            .await
440    }
441
442    /// Like [`api_request`](Self::api_request) but rides through Suno's rate
443    /// limiter, pacing each request to the adaptive rate and backing off through
444    /// the [`Clock`] on a `429` (honouring `Retry-After` when present, defaulting
445    /// to 5s and capped at 60s) or a transient connection failure, up to
446    /// [`API_MAX_RETRIES`] times. Each attempt reconstructs the full request
447    /// (method, path, and body), so a throttled feed page re-POSTs the same
448    /// cursor rather than skipping ahead.
449    ///
450    /// Pacing lives here, at the single per-request layer, rather than in any
451    /// paged walk, so it composes with whatever listing calls it: a page or a
452    /// cursor walk pace identically. The [`AdaptiveLimiter`] paces reactively:
453    /// an unthrottled walk waits nowhere, and only after the first `429` does it
454    /// reserve shared request slots so concurrent callers are spaced in aggregate
455    /// at `1/rate`, widening that spacing as the rate is halved again.
456    ///
457    /// The WAV render flow deliberately keeps to the plain [`api_get`](Self::api_get):
458    /// the executor owns that retry so its budget and poll interval stay in one
459    /// place. Library, playlist, and lineage reads use this so a full-library
460    /// walk is not aborted by a single throttled page.
461    async fn api_send_retrying(
462        &self,
463        http: &impl Http,
464        method: Method,
465        path: &str,
466        body: Vec<u8>,
467    ) -> Result<Vec<u8>> {
468        let pace = self.limiter.lock().unwrap().pace(Instant::now());
469        if !pace.is_zero() {
470            self.clock.sleep(pace).await;
471        }
472        let mut retries = 0;
473        loop {
474            match self.api_request(http, method, path, body.clone()).await {
475                Ok(response) => return Ok(response),
476                Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
477                    self.clock.sleep(retry_after_delay(retry_after)).await;
478                    retries += 1;
479                }
480                Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
481                    self.clock.sleep(backoff_delay(retries, None)).await;
482                    retries += 1;
483                }
484                Err(err) => return Err(err),
485            }
486        }
487    }
488
489    /// Perform an authenticated request, refreshing the JWT once on a 401/403.
490    ///
491    /// `body` is sent only by the adapter when non-empty, so a GET or a bodyless
492    /// POST reaches the network unchanged.
493    async fn api_request(
494        &self,
495        http: &impl Http,
496        method: Method,
497        path: &str,
498        body: Vec<u8>,
499    ) -> Result<Vec<u8>> {
500        // Crate-wide POST allow-list. Every mutating Suno API request funnels
501        // through here, so refusing any POST to a path outside the known-safe
502        // set means a destructive or credit-spending endpoint can never be sent,
503        // even by a future edit that forgets the invariant. GETs are free and
504        // unrestricted; only POSTs are gated.
505        if method == Method::Post && !post_path_allowed(path) {
506            return Err(Error::Refused(format!(
507                "POST to {path} is not on the allow-list"
508            )));
509        }
510        let url = format!("{SUNO_API_BASE_URL}{path}");
511        let mut auth_refreshed = false;
512        loop {
513            let jwt = self.auth.ensure_jwt(self.clock.now_unix(), http).await?;
514            let mut request = match method {
515                Method::Get => HttpRequest::get(url.clone()),
516                Method::Post => HttpRequest::post(url.clone(), body.clone()),
517            };
518            request
519                .headers
520                .push(("Authorization".to_string(), format!("Bearer {jwt}")));
521            let response = http
522                .send(request)
523                .await
524                .map_err(|err| Error::Connection(err.to_string()))?;
525            match response.status {
526                200..=299 => {
527                    self.limiter.lock().unwrap().on_success();
528                    return Ok(response.body);
529                }
530                401 | 403 if !auth_refreshed => {
531                    self.auth.invalidate_jwt();
532                    auth_refreshed = true;
533                }
534                401 | 403 => {
535                    return Err(Error::Auth(format!(
536                        "Suno API auth failed with status {}",
537                        response.status
538                    )));
539                }
540                429 => {
541                    self.limiter.lock().unwrap().on_rate_limit();
542                    return Err(Error::RateLimited {
543                        retry_after: retry_after(&response),
544                    });
545                }
546                400 => {
547                    let preview: String = String::from_utf8_lossy(&response.body)
548                        .chars()
549                        .take(200)
550                        .collect();
551                    return Err(Error::BadRequest(format!(
552                        "Suno API returned 400: {preview}"
553                    )));
554                }
555                404 => {
556                    return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
557                }
558                status => {
559                    let preview: String = String::from_utf8_lossy(&response.body)
560                        .chars()
561                        .take(200)
562                        .collect();
563                    return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
564                }
565            }
566        }
567    }
568}
569
570/// Unwrap a `{ "clip": {...} }` wrapper to the inner clip object, or return
571/// `value` unchanged when it carries no object `clip` key (it is already bare).
572fn unwrap_clip(value: &Value) -> &Value {
573    value
574        .get("clip")
575        .filter(|clip| clip.is_object())
576        .unwrap_or(value)
577}
578
579/// Whether a Suno API path may be the target of a POST (the crate-wide POST
580/// allow-list). Membership is deliberately narrow so a mutating request is only
581/// ever sent to a vetted endpoint:
582///
583/// - [`FEED_V3_PATH`] — the cursor-paginated library listing (a POST by design).
584/// - `…/convert_wav/` — the per-clip server-side lossless WAV render.
585///
586/// A GET is never gated (reads are free and non-mutating). Any credit-spending
587/// generation endpoint is deliberately absent here.
588fn post_path_allowed(path: &str) -> bool {
589    if path == FEED_V3_PATH {
590        return true;
591    }
592    // The per-clip WAV render: /api/gen/{id}/convert_wav/ with a single id.
593    if let Some(rest) = path.strip_prefix("/api/gen/")
594        && let Some(id) = rest.strip_suffix("/convert_wav/")
595    {
596        return is_single_id_segment(id);
597    }
598    false
599}
600
601/// Whether `segment` is a single, non-empty path id segment: no slash, no query,
602/// and no `..` traversal, so an allow-list match can never be smuggled past by a
603/// crafted path.
604fn is_single_id_segment(segment: &str) -> bool {
605    !segment.is_empty()
606        && !segment.contains('/')
607        && !segment.contains('?')
608        && !segment.contains("..")
609}
610
611/// Whether an error is Suno's "this clip has no stems" answer on the stems
612/// page-count endpoint: a `400` (it returns `400 "Invalid page number"` for a
613/// clip with zero stems). Distinguished from a transient `5xx` (also
614/// [`Error::Api`]) so a server error is never mistaken for "no stems".
615fn is_invalid_page_error(err: &Error) -> bool {
616    matches!(err, Error::BadRequest(_))
617}
618
619/// Parse the stems page count from `GET /api/clip/{id}/stems/pages`
620/// (`{"pages": N}`).
621///
622/// A missing, non-numeric, or negative `pages` reads as `0` (no stems), so a
623/// malformed body is treated as indeterminate rather than guessing a count.
624fn parse_stem_page_count(body: &[u8]) -> u32 {
625    serde_json::from_slice::<Value>(body)
626        .ok()
627        .and_then(|data| data.get("pages").and_then(Value::as_u64))
628        .and_then(|pages| u32::try_from(pages).ok())
629        .unwrap_or(0)
630}
631
632/// Parse one page of the stems listing (`{"stems": [<clip>, ...]}`) into
633/// [`Stem`]s.
634///
635/// Each stem is a full clip object, so it is mapped with [`Clip::from_json`]:
636/// the id is the stem clip id, the label is the trailing parenthetical of its
637/// title, and the download URL is its public CDN MP3. Only stems carrying both a
638/// non-empty id and URL are kept — a stem with no id cannot be WAV-rendered, and
639/// one with no URL cannot be mirrored. Malformed JSON yields no stems (never a
640/// panic), so a bad body is treated as an empty, non-authoritative page.
641fn parse_stems_page(body: &[u8]) -> Vec<Stem> {
642    let Ok(data) = serde_json::from_slice::<Value>(body) else {
643        return Vec::new();
644    };
645    let items = if let Some(array) = data.as_array() {
646        array.as_slice()
647    } else {
648        data.get("stems")
649            .and_then(Value::as_array)
650            .map(Vec::as_slice)
651            .unwrap_or(&[])
652    };
653    items
654        .iter()
655        .map(parse_stem)
656        .filter(|stem| !stem.id.is_empty() && !stem.url.is_empty())
657        .collect()
658}
659
660/// Map one raw stem clip element to a [`Stem`]: its clip id, the trailing
661/// parenthetical of its title as the label, and its public CDN MP3 URL.
662fn parse_stem(raw: &Value) -> Stem {
663    let clip = Clip::from_json(raw);
664    Stem {
665        id: clip.id.clone(),
666        label: stem_label_from_title(&clip.title),
667        url: clip.mp3_url(),
668    }
669}
670
671/// The stem label carried in a stem clip's title: the text inside its trailing
672/// parenthetical (`"My Song (Backing Vocals)"` -> `Backing Vocals`). Returns an
673/// empty string when the title has no closing parenthetical, so the caller falls
674/// back to the stem id for naming.
675fn stem_label_from_title(title: &str) -> String {
676    let trimmed = title.trim_end();
677    let Some(before_close) = trimmed.strip_suffix(')') else {
678        return String::new();
679    };
680    match before_close.rfind('(') {
681        Some(open) => before_close[open + 1..].trim().to_string(),
682        None => String::new(),
683    }
684}
685
686/// Drop stems that repeat across pages, keeping the first occurrence of each
687/// download URL so a paged listing counts a stem once.
688fn dedupe_stems(stems: &mut Vec<Stem>) {
689    let mut seen = BTreeSet::new();
690    stems.retain(|stem| seen.insert(stem.url.clone()));
691}
692
693/// Parse a single-clip response body, accepting either a bare clip object or a
694/// `{"clip": {...}}` wrapper. Returns `None` when no clip id is present.
695fn parse_clip(body: &[u8]) -> Option<Clip> {
696    let data: Value = serde_json::from_slice(body).ok()?;
697    let raw = unwrap_clip(&data);
698    let has_id = raw
699        .get("id")
700        .and_then(Value::as_str)
701        .is_some_and(|id| !id.is_empty());
702    has_id.then(|| Clip::from_json(raw))
703}
704
705/// Parse `/api/billing/info/` into the remaining credits we report in `doctor`.
706fn parse_billing_info(body: &[u8]) -> Result<BillingInfo> {
707    let data: Value = serde_json::from_slice(body)
708        .map_err(|err| Error::Api(format!("invalid billing JSON: {err}")))?;
709    let total_credits_left = data
710        .get("total_credits_left")
711        .and_then(json_u64)
712        .ok_or_else(|| Error::Api("invalid billing JSON: missing total_credits_left".into()))?;
713    Ok(BillingInfo { total_credits_left })
714}
715
716/// Read a numeric field that Suno may encode either as a JSON number or a
717/// decimal string.
718fn json_u64(value: &Value) -> Option<u64> {
719    match value {
720        Value::Number(number) => number.as_u64(),
721        Value::String(text) => text.parse().ok(),
722        _ => None,
723    }
724}
725
726/// Build the JSON body for a `POST /api/feed/v3` page.
727///
728/// `filters.trashed` is the string `"False"` so the feed excludes trashed clips
729/// exactly as the old v2 listing did; a `liked` walk adds `filters.liked =
730/// "True"` (v3 ignores an `is_liked` key). The `cursor` is omitted on the first
731/// page and set to the previous page's `next_cursor` thereafter.
732fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
733    let mut filters = serde_json::Map::new();
734    filters.insert("trashed".to_string(), Value::String("False".to_string()));
735    if liked {
736        filters.insert("liked".to_string(), Value::String("True".to_string()));
737    }
738    let mut body = serde_json::Map::new();
739    body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
740    body.insert("filters".to_string(), Value::Object(filters));
741    if let Some(cursor) = cursor {
742        body.insert("cursor".to_string(), Value::String(cursor.to_string()));
743    }
744    serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
745}
746
747/// Parse a v3 feed page into the kept clips, the raw `has_more`, and the
748/// `next_cursor`.
749///
750/// `has_more` is [`None`] when the key is missing or not a bool, so the caller
751/// can refuse to treat an unrecognised page as a fully drained feed. An empty
752/// `next_cursor` string maps to [`None`] so it is never re-sent as a cursor.
753fn parse_feed_v3(body: &[u8]) -> Result<(Vec<Clip>, Option<bool>, Option<String>)> {
754    let data: Value = serde_json::from_slice(body)
755        .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
756    let Some(object) = data.as_object() else {
757        return Ok((Vec::new(), None, None));
758    };
759    let clips = object
760        .get("clips")
761        .and_then(Value::as_array)
762        .map(|raw| {
763            raw.iter()
764                .map(Clip::from_json)
765                .filter(is_downloadable)
766                .collect()
767        })
768        .unwrap_or_default();
769    let has_more = object.get("has_more").and_then(Value::as_bool);
770    let next_cursor = object
771        .get("next_cursor")
772        .and_then(Value::as_str)
773        .filter(|cursor| !cursor.is_empty())
774        .map(str::to_string);
775    Ok((clips, has_more, next_cursor))
776}
777
778/// Parse a `/api/playlist/me` page into playlists, dropping entries with no id.
779fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
780    let data: Value = serde_json::from_slice(body)
781        .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
782    Ok(data
783        .get("playlists")
784        .and_then(Value::as_array)
785        .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
786        .unwrap_or_default())
787}
788
789/// Map one raw `/api/playlist/me` entry, or `None` when it carries no id.
790///
791/// `num_total_results` is the playlist's member count; a missing name defaults
792/// to `Untitled` (matching the clip mapping) so the file name is never empty.
793fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
794    let id = raw
795        .get("id")
796        .and_then(Value::as_str)
797        .filter(|id| !id.is_empty())?
798        .to_string();
799    let name = match raw.get("name") {
800        Some(Value::String(name)) if !name.is_empty() => name.clone(),
801        _ => "Untitled".to_string(),
802    };
803    let num_clips = raw
804        .get("num_total_results")
805        .and_then(Value::as_u64)
806        .unwrap_or(0);
807    Some(Playlist {
808        id,
809        name,
810        num_clips,
811    })
812}
813
814/// Parse a `/api/playlist/{id}/` body into its ordered member clips plus a
815/// completeness flag.
816///
817/// Each `playlist_clips[]` entry wraps the clip under `clip`; the wrapper is
818/// unwrapped (falling back to the entry itself), order is preserved exactly, and
819/// only clips with a non-empty id survive. No downloadability filter is applied:
820/// a playlist may hold any clip, and members absent from the local library are
821/// reconciled as comment lines by the caller, not dropped here. The scoped-sync
822/// path applies [`is_downloadable`](crate::is_downloadable) itself when it fetches
823/// members as download candidates.
824///
825/// The completeness flag is `true` only when the response's `num_total_results`
826/// is present, equals the raw `playlist_clips[]` count, and no member was
827/// dropped by the empty-id filter, i.e. the whole member set arrived intact on
828/// this single page. It gates a Mirror playlist area's deletion authority (D5):
829/// a short or paginated page, or one carrying a member with a missing/empty
830/// clip id, cannot be authoritative for deletion, so it returns `false`.
831fn parse_playlist_clips(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
832    let data: Value = serde_json::from_slice(body)
833        .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
834    let raw = data.get("playlist_clips").and_then(Value::as_array);
835    let raw_len = raw.map(|a| a.len()).unwrap_or(0);
836    let clips: Vec<Clip> = raw
837        .map(|raw| {
838            raw.iter()
839                .map(|entry| Clip::from_json(unwrap_clip(entry)))
840                .filter(|clip| !clip.id.is_empty())
841                .collect()
842        })
843        .unwrap_or_default();
844    // Completeness requires the reported total to be present and to match the
845    // raw entry count (before the empty-id filter) AND no member to have been
846    // dropped by that filter (`clips.len() == raw_len`). A missing or malformed
847    // total, a short page, or a single dropped member (empty/missing clip id)
848    // all fail safe toward "not authoritative", so a Mirror area can never
849    // delete from a page whose whole member set was not seen intact.
850    let complete = data
851        .get("num_total_results")
852        .and_then(Value::as_u64)
853        .is_some_and(|total| raw_len as u64 == total && clips.len() == raw_len);
854    Ok((clips, complete))
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
861    use std::time::Duration;
862
863    fn feed_body() -> String {
864        serde_json::json!({
865            "has_more": false,
866            "clips": [
867                {
868                    "id": "a", "title": "Song A", "status": "complete",
869                    "audio_url": "https://cdn1.suno.ai/a.mp3",
870                    "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
871                },
872                {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
873                {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
874                {
875                    "id": "d", "title": "Context", "status": "complete",
876                    "metadata": {"type": "rendered_context_window"}
877                }
878            ]
879        })
880        .to_string()
881    }
882
883    #[test]
884    fn parse_feed_v3_filters_and_reads_pagination() {
885        let (clips, has_more, next_cursor) = parse_feed_v3(feed_body().as_bytes()).unwrap();
886        assert_eq!(has_more, Some(false));
887        assert_eq!(next_cursor, None);
888        assert_eq!(clips.len(), 1);
889        assert_eq!(clips[0].id, "a");
890        assert_eq!(clips[0].tags, "rock");
891        assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
892    }
893
894    #[test]
895    fn feed_v3_body_carries_filters_and_optional_cursor() {
896        let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
897        assert_eq!(first["filters"]["trashed"], "False");
898        assert!(first.get("cursor").is_none());
899        assert!(first["filters"].get("liked").is_none());
900
901        let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
902        assert_eq!(liked["filters"]["liked"], "True");
903        assert_eq!(liked["cursor"], "cur42");
904    }
905
906    #[test]
907    fn audiopipe_url_is_rewritten_to_cdn() {
908        let raw =
909            serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
910        assert_eq!(
911            Clip::from_json(&raw).audio_url,
912            "https://cdn1.suno.ai/x.mp3"
913        );
914    }
915
916    #[test]
917    fn list_clips_authenticates_then_reads_the_feed() {
918        let client_body = serde_json::json!({
919            "response": {
920                "last_active_session_id": "s",
921                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
922            }
923        })
924        .to_string();
925        let http = MockHttp::new(vec![
926            Rule::new(
927                "/v1/client/sessions/",
928                200,
929                r#"{"jwt": "a.b.c"}"#.to_string(),
930            ),
931            Rule::new("/v1/client", 200, client_body),
932            Rule::new("/api/feed/v3", 200, feed_body()),
933        ]);
934
935        let auth = ClerkAuth::new("eyJtoken");
936        pollster::block_on(auth.authenticate(&http)).unwrap();
937        let client = SunoClient::new(auth, RecordingClock::new());
938        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
939        assert_eq!(clips.len(), 1);
940        assert_eq!(clips[0].id, "a");
941        assert!(complete);
942    }
943
944    #[test]
945    fn api_request_uses_clock_now_unix_for_jwt_expiry() {
946        use crate::consts::JWT_REFRESH_BUFFER;
947        use base64::Engine;
948        let exp = 1_000_000i64;
949        let payload =
950            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#));
951        let jwt_str = format!("hdr.{}.sig", payload);
952        let token_body = format!(r#"{{"jwt": "{jwt_str}"}}"#);
953        let client_body = serde_json::json!({
954            "response": {
955                "last_active_session_id": "s",
956                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
957            }
958        })
959        .to_string();
960
961        let make_http = || {
962            ScriptedHttp::new()
963                .route("/v1/client/sessions/", Reply::json(&token_body))
964                .route("/v1/client", Reply::json(&client_body))
965                .route("/api/feed/v3", Reply::json(&feed_body()))
966        };
967
968        // At the refresh boundary: ensure_jwt triggers a second refresh_jwt call.
969        let http = make_http();
970        let auth = ClerkAuth::new("eyJtoken");
971        pollster::block_on(auth.authenticate(&http)).unwrap();
972        let client = SunoClient::new(auth, RecordingClock::at(exp - JWT_REFRESH_BUFFER));
973        let (clips, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
974        assert_eq!(clips.len(), 1);
975        // authenticate + api_request refresh = 2 token calls.
976        assert_eq!(http.count("/v1/client/sessions/"), 2);
977
978        // Just before the boundary: no additional refresh.
979        let http2 = make_http();
980        let auth2 = ClerkAuth::new("eyJtoken");
981        pollster::block_on(auth2.authenticate(&http2)).unwrap();
982        let client2 = SunoClient::new(auth2, RecordingClock::at(exp - JWT_REFRESH_BUFFER - 1));
983        let (clips2, _) = pollster::block_on(client2.list_clips(&http2, false, None)).unwrap();
984        assert_eq!(clips2.len(), 1);
985        // Only authenticate's token call; no extra refresh.
986        assert_eq!(http2.count("/v1/client/sessions/"), 1);
987    }
988
989    #[test]
990    fn list_clips_reports_incomplete_when_paging_is_capped() {
991        let mut rules = auth_rules();
992        rules.push(Rule::new(
993            "/api/feed/v3",
994            200,
995            serde_json::json!({
996                "has_more": true,
997                "next_cursor": "cur1",
998                "clips": [{
999                    "id": "a", "title": "Song A", "status": "complete",
1000                    "audio_url": "https://cdn1.suno.ai/a.mp3",
1001                    "metadata": {"type": "gen"}
1002                }]
1003            })
1004            .to_string(),
1005        ));
1006        let http = MockHttp::new(rules);
1007        let client = authed_client(&http);
1008
1009        let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1010        assert!(!complete);
1011    }
1012
1013    fn auth_rules() -> Vec<Rule> {
1014        let client_body = serde_json::json!({
1015            "response": {
1016                "last_active_session_id": "s",
1017                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
1018            }
1019        })
1020        .to_string();
1021        vec![
1022            Rule::new(
1023                "/v1/client/sessions/",
1024                200,
1025                r#"{"jwt": "a.b.c"}"#.to_string(),
1026            ),
1027            Rule::new("/v1/client", 200, client_body),
1028        ]
1029    }
1030
1031    fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
1032        let auth = ClerkAuth::new("eyJtoken");
1033        pollster::block_on(auth.authenticate(http)).unwrap();
1034        SunoClient::new(auth, RecordingClock::new())
1035    }
1036
1037    #[test]
1038    fn get_billing_info_reads_remaining_credits() {
1039        let mut rules = auth_rules();
1040        rules.push(Rule::new(
1041            BILLING_INFO_PATH,
1042            200,
1043            r#"{"total_credits_left":500,"monthly_limit":1000,"monthly_usage":500}"#.to_string(),
1044        ));
1045        let http = MockHttp::new(rules);
1046        let client = authed_client(&http);
1047
1048        let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
1049        assert_eq!(billing.total_credits_left, 500);
1050    }
1051
1052    #[test]
1053    fn get_billing_info_rejects_missing_balance() {
1054        let mut rules = auth_rules();
1055        rules.push(Rule::new(
1056            BILLING_INFO_PATH,
1057            200,
1058            r#"{"monthly_usage":12}"#.to_string(),
1059        ));
1060        let http = MockHttp::new(rules);
1061        let client = authed_client(&http);
1062
1063        let err = pollster::block_on(client.get_billing_info(&http)).unwrap_err();
1064        assert!(err.to_string().contains("total_credits_left"));
1065    }
1066
1067    #[test]
1068    fn aligned_lyrics_reads_words_and_lines() {
1069        let mut rules = auth_rules();
1070        let body = serde_json::json!({
1071            "aligned_words": [
1072                {"word": "hi", "success": true, "start_s": 0.5, "end_s": 0.9, "p_align": 0.99}
1073            ],
1074            "aligned_lyrics": [
1075                {"text": "hi", "start_s": 0.5, "end_s": 0.9, "section": "Verse 1",
1076                 "words": [{"text": "hi", "start_s": 0.5, "end_s": 0.9}]}
1077            ],
1078            "hoot_cer": 0.2, "is_streamed": false
1079        })
1080        .to_string();
1081        rules.push(Rule::new("/aligned_lyrics/v2/", 200, body));
1082        let http = MockHttp::new(rules);
1083        let client = authed_client(&http);
1084
1085        let aligned = pollster::block_on(client.aligned_lyrics(&http, "clip-1")).unwrap();
1086        assert_eq!(aligned.words.len(), 1);
1087        assert_eq!(aligned.lines.len(), 1);
1088        assert_eq!(aligned.lines[0].section, "Verse 1");
1089        assert!(!aligned.is_empty());
1090    }
1091
1092    #[test]
1093    fn aligned_lyrics_empty_arrays_map_to_empty() {
1094        let mut rules = auth_rules();
1095        rules.push(Rule::new(
1096            "/aligned_lyrics/v2/",
1097            200,
1098            r#"{"aligned_words":[],"aligned_lyrics":[],"hoot_cer":1.0}"#.to_string(),
1099        ));
1100        let http = MockHttp::new(rules);
1101        let client = authed_client(&http);
1102
1103        let aligned = pollster::block_on(client.aligned_lyrics(&http, "instr")).unwrap();
1104        assert!(aligned.is_empty());
1105    }
1106
1107    #[test]
1108    fn aligned_lyrics_maps_404_to_empty() {
1109        let mut rules = auth_rules();
1110        rules.push(Rule::new(
1111            "/aligned_lyrics/v2/",
1112            404,
1113            "not found".to_string(),
1114        ));
1115        let http = MockHttp::new(rules);
1116        let client = authed_client(&http);
1117
1118        let aligned = pollster::block_on(client.aligned_lyrics(&http, "missing")).unwrap();
1119        assert!(aligned.is_empty());
1120    }
1121
1122    fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
1123        let auth = ClerkAuth::new("eyJtoken");
1124        pollster::block_on(auth.authenticate(http)).unwrap();
1125        SunoClient::new(auth, clock)
1126    }
1127
1128    fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
1129        let mut page = serde_json::json!({
1130            "has_more": next_cursor.is_some(),
1131            "clips": [{
1132                "id": id, "title": "Song", "status": "complete",
1133                "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
1134                "metadata": {"type": "gen"}
1135            }]
1136        });
1137        if let Some(cursor) = next_cursor {
1138            page["next_cursor"] = serde_json::json!(cursor);
1139        }
1140        page.to_string()
1141    }
1142
1143    #[test]
1144    fn list_clips_retries_a_rate_limited_page() {
1145        let http = ScriptedHttp::new().with_auth().route_seq(
1146            "/api/feed/v3",
1147            vec![Reply::status(429), Reply::json(&feed_body())],
1148        );
1149        let clock = RecordingClock::new();
1150        let client = scripted_client(&http, clock.clone());
1151
1152        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1153        assert_eq!(clips.len(), 1);
1154        assert!(complete);
1155        // The throttled page was retried once, waiting the default post-429 wait.
1156        assert_eq!(http.count("/api/feed/v3"), 2);
1157        assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
1158    }
1159
1160    #[test]
1161    fn list_clips_honours_retry_after_on_a_throttled_page() {
1162        let http = ScriptedHttp::new().with_auth().route_seq(
1163            "/api/feed/v3",
1164            vec![
1165                Reply::status(429).with_retry_after(7),
1166                Reply::json(&feed_body()),
1167            ],
1168        );
1169        let clock = RecordingClock::new();
1170        let client = scripted_client(&http, clock.clone());
1171
1172        let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1173        assert_eq!(clips.len(), 1);
1174        // The server's Retry-After is honoured directly as the post-429 wait.
1175        assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
1176    }
1177
1178    #[test]
1179    fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
1180        // A 429 mid-walk must re-POST the *same* cursor, not skip a page.
1181        let http = ScriptedHttp::new().with_auth().route_seq(
1182            "/api/feed/v3",
1183            vec![
1184                Reply::json(&one_clip_page("a", Some("cur1"))),
1185                Reply::status(429),
1186                Reply::json(&one_clip_page("b", None)),
1187            ],
1188        );
1189        let clock = RecordingClock::new();
1190        let client = scripted_client(&http, clock.clone());
1191
1192        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1193        assert!(complete);
1194        assert_eq!(clips.len(), 2);
1195        let bodies = http.bodies();
1196        let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
1197        assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
1198        // The retry (body 2) carries the SAME cursor as the throttled call (body 2 == the
1199        // second feed POST), i.e. the cursor from page 1's next_cursor.
1200        let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
1201        let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
1202        assert_eq!(retried["cursor"], "cur1");
1203        assert_eq!(after_retry["cursor"], "cur1");
1204    }
1205
1206    #[test]
1207    fn list_clips_threads_the_cursor_across_pages() {
1208        let http = ScriptedHttp::new().with_auth().route_seq(
1209            "/api/feed/v3",
1210            vec![
1211                Reply::json(&one_clip_page("a", Some("cur1"))),
1212                Reply::json(&one_clip_page("b", None)),
1213            ],
1214        );
1215        let clock = RecordingClock::new();
1216        let client = scripted_client(&http, clock.clone());
1217
1218        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1219        assert!(complete);
1220        assert_eq!(clips.len(), 2);
1221        let bodies = http.bodies();
1222        let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
1223        assert_eq!(feed_bodies.len(), 2);
1224        let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
1225        let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
1226        // Page 1 omits the cursor; page 2 carries exactly page 1's next_cursor.
1227        assert!(page1.get("cursor").is_none());
1228        assert_eq!(page2["cursor"], "cur1");
1229    }
1230
1231    #[test]
1232    fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
1233        // has_more == true with no usable next_cursor: a truncated feed. The walk
1234        // must stop, report incomplete, and never re-POST a null cursor.
1235        let page = serde_json::json!({
1236            "has_more": true,
1237            "clips": [{
1238                "id": "a", "title": "Song", "status": "complete",
1239                "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
1240            }]
1241        })
1242        .to_string();
1243        let http = ScriptedHttp::new()
1244            .with_auth()
1245            .route("/api/feed/v3", Reply::json(&page));
1246        let clock = RecordingClock::new();
1247        let client = scripted_client(&http, clock.clone());
1248
1249        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1250        assert!(!complete);
1251        assert_eq!(clips.len(), 1);
1252        assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
1253    }
1254
1255    #[test]
1256    fn list_clips_is_incomplete_when_has_more_is_missing() {
1257        // A page with no has_more key must not be read as a fully drained feed.
1258        let page = serde_json::json!({
1259            "clips": [{
1260                "id": "a", "title": "Song", "status": "complete",
1261                "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
1262            }]
1263        })
1264        .to_string();
1265        let http = ScriptedHttp::new()
1266            .with_auth()
1267            .route("/api/feed/v3", Reply::json(&page));
1268        let clock = RecordingClock::new();
1269        let client = scripted_client(&http, clock.clone());
1270
1271        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1272        assert!(!complete);
1273        assert_eq!(clips.len(), 1);
1274        assert_eq!(http.count("/api/feed/v3"), 1);
1275    }
1276
1277    #[test]
1278    fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
1279        let http = ScriptedHttp::new().with_auth().route_seq(
1280            "/api/feed/v3",
1281            vec![
1282                Reply::json(&one_clip_page("a", Some("cur1"))),
1283                Reply::status(500),
1284            ],
1285        );
1286        let clock = RecordingClock::new();
1287        let client = scripted_client(&http, clock.clone());
1288
1289        let result = pollster::block_on(client.list_clips(&http, false, None));
1290        assert!(matches!(result, Err(Error::Api(_))));
1291    }
1292
1293    #[test]
1294    fn list_clips_is_complete_on_an_empty_drained_feed() {
1295        // An empty but fully drained feed is authoritative (complete = true);
1296        // deletion is separately gated by there being a mirror source.
1297        let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
1298        let http = ScriptedHttp::new()
1299            .with_auth()
1300            .route("/api/feed/v3", Reply::json(&page));
1301        let clock = RecordingClock::new();
1302        let client = scripted_client(&http, clock.clone());
1303
1304        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1305        assert!(complete);
1306        assert!(clips.is_empty());
1307    }
1308
1309    #[test]
1310    fn list_clips_liked_scope_sends_the_liked_filter() {
1311        let http = ScriptedHttp::new()
1312            .with_auth()
1313            .route("/api/feed/v3", Reply::json(&feed_body()));
1314        let clock = RecordingClock::new();
1315        let client = scripted_client(&http, clock.clone());
1316
1317        let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
1318        let bodies = http.bodies();
1319        let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
1320        let value: Value = serde_json::from_str(feed_body).unwrap();
1321        assert_eq!(value["filters"]["liked"], "True");
1322        assert_eq!(value["filters"]["trashed"], "False");
1323    }
1324
1325    #[test]
1326    fn list_clips_does_not_pace_an_unthrottled_walk() {
1327        let http = ScriptedHttp::new().with_auth().route_seq(
1328            "/api/feed/v3",
1329            vec![
1330                Reply::json(&one_clip_page("a", Some("cur1"))),
1331                Reply::json(&one_clip_page("e", None)),
1332            ],
1333        );
1334        let clock = RecordingClock::new();
1335        let client = scripted_client(&http, clock.clone());
1336
1337        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1338        assert!(complete);
1339        assert_eq!(clips.len(), 2);
1340        assert_eq!(http.count("/api/feed/v3"), 2);
1341        // Pacing is reactive: with no 429 the whole walk waits nowhere.
1342        assert!(clock.sleeps().is_empty());
1343    }
1344
1345    #[test]
1346    fn list_clips_slows_its_pace_after_a_throttled_page() {
1347        let http = ScriptedHttp::new().with_auth().route_seq(
1348            "/api/feed/v3",
1349            vec![
1350                Reply::status(429),
1351                Reply::json(&one_clip_page("a", Some("cur1"))),
1352                Reply::json(&one_clip_page("e", None)),
1353            ],
1354        );
1355        let clock = RecordingClock::new();
1356        let client = scripted_client(&http, clock.clone());
1357
1358        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1359        assert!(complete);
1360        assert_eq!(clips.len(), 2);
1361        // The 429 halved the rate, so the default post-429 wait is followed by a
1362        // doubled inter-page pace (500ms to 1s) for the next page.
1363        assert_eq!(
1364            clock.sleeps(),
1365            vec![Duration::from_secs(5), Duration::from_secs(1)]
1366        );
1367    }
1368
1369    #[test]
1370    fn list_clips_gives_up_after_max_retries() {
1371        let http = ScriptedHttp::new()
1372            .with_auth()
1373            .route("/api/feed/v3", Reply::status(429));
1374        let clock = RecordingClock::new();
1375        let client = scripted_client(&http, clock.clone());
1376
1377        let result = pollster::block_on(client.list_clips(&http, false, None));
1378        assert!(matches!(result, Err(Error::RateLimited { .. })));
1379        let budget = crate::consts::API_MAX_RETRIES as usize;
1380        assert_eq!(clock.sleeps().len(), budget);
1381        assert_eq!(http.count("/api/feed/v3"), budget + 1);
1382    }
1383
1384    #[test]
1385    fn parse_clip_accepts_bare_and_wrapped_shapes() {
1386        let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
1387        assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
1388
1389        let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
1390        assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
1391
1392        let missing = serde_json::json!({"detail": "not found"}).to_string();
1393        assert!(parse_clip(missing.as_bytes()).is_none());
1394    }
1395
1396    #[test]
1397    fn get_clip_uses_the_dedicated_endpoint() {
1398        let clip_body = serde_json::json!({
1399            "id": "z", "title": "Zed", "status": "complete",
1400            "audio_url": "https://cdn1.suno.ai/z.mp3",
1401            "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
1402        })
1403        .to_string();
1404        let mut rules = auth_rules();
1405        rules.push(Rule::new("/api/clip/", 200, clip_body));
1406        let http = MockHttp::new(rules);
1407        let client = authed_client(&http);
1408
1409        let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
1410        assert_eq!(clip.id, "z");
1411        assert_eq!(clip.title, "Zed");
1412        assert_eq!(clip.tags, "jazz");
1413    }
1414
1415    #[test]
1416    fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
1417        let mut rules = auth_rules();
1418        rules.push(Rule::new(
1419            "/api/clip/",
1420            404,
1421            r#"{"detail": "not found"}"#.to_string(),
1422        ));
1423        rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
1424        let http = MockHttp::new(rules);
1425        let client = authed_client(&http);
1426
1427        let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
1428        assert_eq!(clip.id, "a");
1429        assert_eq!(clip.tags, "rock");
1430    }
1431
1432    #[test]
1433    fn request_wav_accepts_a_2xx_status() {
1434        let mut rules = auth_rules();
1435        rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
1436        let http = MockHttp::new(rules);
1437        let client = authed_client(&http);
1438
1439        assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
1440    }
1441
1442    #[test]
1443    fn wav_url_reads_the_ready_url() {
1444        let mut rules = auth_rules();
1445        rules.push(Rule::new(
1446            "/wav_file/",
1447            200,
1448            r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
1449        ));
1450        let http = MockHttp::new(rules);
1451        let client = authed_client(&http);
1452
1453        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1454        assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
1455    }
1456
1457    #[test]
1458    fn wav_url_is_none_until_the_render_is_ready() {
1459        let mut rules = auth_rules();
1460        rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
1461        let http = MockHttp::new(rules);
1462        let client = authed_client(&http);
1463
1464        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
1465        assert_eq!(url, None);
1466    }
1467
1468    #[test]
1469    fn get_clips_by_ids_fetches_each_id_and_keeps_artefacts() {
1470        // The per-id gap-fill path must not apply the listing's downloadability
1471        // filter: an infill ancestor and an upload root both survive, fetched one
1472        // `/api/clip/{id}` at a time.
1473        let p1 = serde_json::json!({
1474            "id": "p1", "title": "Infill Ancestor", "status": "complete",
1475            "metadata": {"type": "gen", "task": "infill"}
1476        })
1477        .to_string();
1478        let p2 = serde_json::json!({
1479            "id": "p2", "title": "Uploaded Root", "status": "complete",
1480            "metadata": {"type": "upload"}
1481        })
1482        .to_string();
1483        let mut rules = auth_rules();
1484        rules.push(Rule::new("/api/clip/p1", 200, p1));
1485        rules.push(Rule::new("/api/clip/p2", 200, p2));
1486        let http = MockHttp::new(rules);
1487        let client = authed_client(&http);
1488
1489        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"], 4)).unwrap();
1490        assert_eq!(
1491            clips.len(),
1492            2,
1493            "infill and upload ancestors must not be filtered"
1494        );
1495        assert_eq!(clips[0].id, "p1");
1496        assert_eq!(clips[1].id, "p2");
1497    }
1498
1499    #[test]
1500    fn get_clips_by_ids_returns_a_trashed_clip() {
1501        // A trashed ancestor must still be retrievable by id (the v2 `?ids=`
1502        // capability that per-id `/api/clip/{id}` replaces).
1503        let trashed = serde_json::json!({
1504            "id": "t1", "title": "Trashed Ancestor", "status": "complete",
1505            "is_trashed": true, "metadata": {"type": "gen"}
1506        })
1507        .to_string();
1508        let mut rules = auth_rules();
1509        rules.push(Rule::new("/api/clip/t1", 200, trashed));
1510        let http = MockHttp::new(rules);
1511        let client = authed_client(&http);
1512
1513        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"], 4)).unwrap();
1514        assert_eq!(clips.len(), 1);
1515        assert_eq!(clips[0].id, "t1");
1516        assert!(clips[0].is_trashed);
1517    }
1518
1519    #[test]
1520    fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
1521        let only = serde_json::json!({
1522            "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
1523        })
1524        .to_string();
1525        let http = ScriptedHttp::new()
1526            .with_auth()
1527            .route("/api/clip/gone", Reply::status(404))
1528            .route("/api/clip/only", Reply::json(&only));
1529        let client = scripted_client(&http, RecordingClock::new());
1530
1531        let clips =
1532            pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"], 4))
1533                .unwrap();
1534        assert_eq!(clips.len(), 1, "the 404 id is skipped");
1535        assert_eq!(clips[0].id, "only");
1536        // "only" is fetched once despite appearing twice; "gone" is attempted once.
1537        assert_eq!(http.count("/api/clip/only"), 1);
1538        assert_eq!(http.count("/api/clip/gone"), 1);
1539    }
1540
1541    #[test]
1542    fn get_clips_by_ids_matches_serial_results_and_keeps_order_when_concurrent() {
1543        let a = serde_json::json!({
1544            "id": "a", "title": "A", "status": "complete", "metadata": {"type": "gen"}
1545        })
1546        .to_string();
1547        let b = serde_json::json!({
1548            "id": "b", "title": "B", "status": "complete", "metadata": {"type": "gen"}
1549        })
1550        .to_string();
1551        let c = serde_json::json!({
1552            "id": "c", "title": "C", "status": "complete", "metadata": {"type": "gen"}
1553        })
1554        .to_string();
1555        let http = ScriptedHttp::new()
1556            .with_auth()
1557            .route("/api/clip/a", Reply::json(&a))
1558            .route("/api/clip/b", Reply::json(&b))
1559            .route("/api/clip/c", Reply::json(&c));
1560        let client = scripted_client(&http, RecordingClock::new());
1561        let ids = ["b", "a", "c", "a"];
1562
1563        let serial = pollster::block_on(client.get_clips_by_ids(&http, &ids, 1)).unwrap();
1564        let concurrent = pollster::block_on(client.get_clips_by_ids(&http, &ids, 4)).unwrap();
1565
1566        let serial_ids: Vec<&str> = serial.iter().map(|clip| clip.id.as_str()).collect();
1567        let concurrent_ids: Vec<&str> = concurrent.iter().map(|clip| clip.id.as_str()).collect();
1568        assert_eq!(serial_ids, vec!["b", "a", "c"]);
1569        assert_eq!(concurrent_ids, serial_ids);
1570    }
1571
1572    #[test]
1573    fn concurrent_reads_share_aggregate_pacing_after_first_rate_limit() {
1574        // Four concurrent requests at 1 req/s should span ~3s from first to last
1575        // reserved slot, with a small tolerance for runtime scheduling jitter.
1576        const EXPECTED_SPAN: Duration = Duration::from_secs(3);
1577        const TOLERANCE: Duration = Duration::from_millis(50);
1578        let ids = ["a", "b", "c", "d"];
1579        let a =
1580            serde_json::json!({"id":"a","title":"A","status":"complete","metadata":{"type":"gen"}})
1581                .to_string();
1582        let b =
1583            serde_json::json!({"id":"b","title":"B","status":"complete","metadata":{"type":"gen"}})
1584                .to_string();
1585        let c =
1586            serde_json::json!({"id":"c","title":"C","status":"complete","metadata":{"type":"gen"}})
1587                .to_string();
1588        let d =
1589            serde_json::json!({"id":"d","title":"D","status":"complete","metadata":{"type":"gen"}})
1590                .to_string();
1591        let http = ScriptedHttp::new()
1592            .with_auth()
1593            .route_seq(
1594                "/api/feed/v3",
1595                vec![
1596                    Reply::status(429),
1597                    Reply::json(&one_clip_page("seed", None)),
1598                ],
1599            )
1600            .route("/api/clip/a", Reply::json(&a))
1601            .route("/api/clip/b", Reply::json(&b))
1602            .route("/api/clip/c", Reply::json(&c))
1603            .route("/api/clip/d", Reply::json(&d));
1604        let clock = RecordingClock::new();
1605        let client = scripted_client(&http, clock.clone());
1606        pollster::block_on(client.list_clips(&http, false, Some(1))).unwrap();
1607        let before = clock.sleeps().len();
1608
1609        let clips = pollster::block_on(client.get_clips_by_ids(&http, &ids, ids.len())).unwrap();
1610        assert_eq!(clips.len(), ids.len());
1611        let sleeps = clock.sleeps();
1612        let paced = &sleeps[before..];
1613        assert_eq!(paced.len(), ids.len());
1614        let min = paced.iter().copied().min().unwrap();
1615        let max = paced.iter().copied().max().unwrap();
1616        let span = max.saturating_sub(min);
1617        // After the first 429, rate halves from 2 -> 1 req/s. Under shared slot
1618        // pacing, four concurrent reads are dispatched one second apart in
1619        // aggregate, so the first-to-last spacing is about three seconds.
1620        assert!(span >= EXPECTED_SPAN.saturating_sub(TOLERANCE));
1621        assert!(span <= EXPECTED_SPAN + TOLERANCE);
1622    }
1623
1624    #[test]
1625    fn get_clip_parent_reads_the_parent_clip() {
1626        let parent = serde_json::json!({
1627            "id": "par", "title": "Ancestor", "status": "complete",
1628            "metadata": {"type": "gen"}
1629        })
1630        .to_string();
1631        let mut rules = auth_rules();
1632        rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
1633        let http = MockHttp::new(rules);
1634        let client = authed_client(&http);
1635
1636        let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
1637        assert_eq!(clip.unwrap().id, "par");
1638    }
1639
1640    #[test]
1641    fn get_clip_parent_is_none_for_a_root() {
1642        let mut rules = auth_rules();
1643        rules.push(Rule::new(
1644            "/api/clips/parent",
1645            404,
1646            r#"{"detail": "no parent"}"#.to_string(),
1647        ));
1648        let http = MockHttp::new(rules);
1649        let client = authed_client(&http);
1650
1651        let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
1652        assert!(clip.is_none());
1653    }
1654
1655    #[test]
1656    fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
1657        // A transient 5xx must never be mistaken for "this clip is a root":
1658        // folding it into Ok(None) would fabricate a wrong external root and let
1659        // a blip rewrite lineage (HARDENING H3). Only a real 404 means no parent.
1660        for status in [500u16, 503] {
1661            let mut rules = auth_rules();
1662            rules.push(Rule::new(
1663                "/api/clips/parent",
1664                status,
1665                r#"{"detail": "server error"}"#.to_string(),
1666            ));
1667            let http = MockHttp::new(rules);
1668            let client = authed_client(&http);
1669
1670            let result = pollster::block_on(client.get_clip_parent(&http, "child"));
1671            assert!(
1672                matches!(result, Err(Error::Api(_))),
1673                "status {status} must propagate as an error, not Ok(None)"
1674            );
1675        }
1676    }
1677
1678    #[test]
1679    fn get_playlists_maps_entries_and_skips_missing_ids() {
1680        let page1 = serde_json::json!({
1681            "playlists": [
1682                {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
1683                {"id": "", "name": "No Id", "num_total_results": 3},
1684                {"name": "Also No Id"}
1685            ]
1686        })
1687        .to_string();
1688        let mut rules = auth_rules();
1689        // Page 1 returns entries; page 2 is empty, ending pagination.
1690        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1691        rules.push(Rule::new(
1692            "/api/playlist/me?page=2",
1693            200,
1694            r#"{"playlists": []}"#.to_string(),
1695        ));
1696        let http = MockHttp::new(rules);
1697        let client = authed_client(&http);
1698
1699        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1700        assert_eq!(playlists.len(), 1, "entries without an id are dropped");
1701        assert_eq!(
1702            playlists[0],
1703            Playlist {
1704                id: "pl1".to_owned(),
1705                name: "Road Trip".to_owned(),
1706                num_clips: 12,
1707            }
1708        );
1709    }
1710
1711    #[test]
1712    fn get_playlists_defaults_a_missing_name_to_untitled() {
1713        let page1 = serde_json::json!({
1714            "playlists": [{"id": "pl9", "num_total_results": 1}]
1715        })
1716        .to_string();
1717        let mut rules = auth_rules();
1718        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1719        rules.push(Rule::new(
1720            "/api/playlist/me?page=2",
1721            200,
1722            r#"{"playlists": []}"#.to_string(),
1723        ));
1724        let http = MockHttp::new(rules);
1725        let client = authed_client(&http);
1726
1727        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1728        assert_eq!(playlists[0].name, "Untitled");
1729    }
1730
1731    #[test]
1732    fn get_playlist_clips_preserves_order_and_unwraps_clip() {
1733        // Members arrive wrapped under `clip`, in playlist order, already
1734        // non-trashed. Order is preserved and no downloadability filter is applied.
1735        let body = serde_json::json!({
1736            "num_total_results": 2,
1737            "playlist_clips": [
1738                {"clip": {
1739                    "id": "second", "title": "Second", "status": "complete",
1740                    "metadata": {"duration": 60.0, "type": "gen"}
1741                }},
1742                {"clip": {
1743                    "id": "first", "title": "First", "status": "complete",
1744                    "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
1745                }}
1746            ]
1747        })
1748        .to_string();
1749        let mut rules = auth_rules();
1750        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1751        let http = MockHttp::new(rules);
1752        let client = authed_client(&http);
1753
1754        let (clips, complete) =
1755            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1756        assert_eq!(clips.len(), 2, "an infill member is not filtered out");
1757        assert_eq!(clips[0].id, "second");
1758        assert_eq!(clips[1].id, "first");
1759        assert!(
1760            complete,
1761            "returned == num_total_results is fully enumerated"
1762        );
1763    }
1764
1765    #[test]
1766    fn get_playlist_clips_short_page_is_not_complete() {
1767        // A page with fewer entries than num_total_results is not authoritative.
1768        let body = serde_json::json!({
1769            "num_total_results": 5,
1770            "playlist_clips": [
1771                {"clip": {
1772                    "id": "only", "title": "Only", "status": "complete",
1773                    "metadata": {"duration": 60.0, "type": "gen"}
1774                }}
1775            ]
1776        })
1777        .to_string();
1778        let mut rules = auth_rules();
1779        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1780        let http = MockHttp::new(rules);
1781        let client = authed_client(&http);
1782
1783        let (clips, complete) =
1784            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1785        assert_eq!(clips.len(), 1);
1786        assert!(!complete, "a short page is not fully enumerated");
1787    }
1788
1789    #[test]
1790    fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
1791        let mut rules = auth_rules();
1792        rules.push(Rule::new(
1793            "/api/playlist/empty/",
1794            200,
1795            r#"{"num_total_results": 0, "playlist_clips": []}"#.to_string(),
1796        ));
1797        let http = MockHttp::new(rules);
1798        let client = authed_client(&http);
1799
1800        let (clips, complete) =
1801            pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
1802        assert!(clips.is_empty());
1803        assert!(
1804            complete,
1805            "an empty playlist reporting zero total is complete"
1806        );
1807    }
1808
1809    #[test]
1810    fn get_playlist_clips_missing_total_is_not_complete() {
1811        // A body without num_total_results cannot be verified as whole, so it is
1812        // never authoritative -- an empty or malformed page must not let a Mirror
1813        // area delete from it (D5).
1814        let mut rules = auth_rules();
1815        rules.push(Rule::new(
1816            "/api/playlist/pl1/",
1817            200,
1818            r#"{"playlist_clips": []}"#.to_string(),
1819        ));
1820        let http = MockHttp::new(rules);
1821        let client = authed_client(&http);
1822
1823        let (clips, complete) =
1824            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1825        assert!(clips.is_empty());
1826        assert!(!complete, "a missing total is never fully enumerated");
1827    }
1828
1829    #[test]
1830    fn get_playlist_clips_dropped_member_disarms_authority() {
1831        // A member whose clip carries no usable id is dropped by the empty-id
1832        // filter, so clips.len() < raw_len even when raw_len == num_total_results.
1833        // Both a missing `id` key and an empty-string `id` must disarm deletion
1834        // authority rather than silently arming a Mirror area on a short set.
1835        let missing_id = serde_json::json!({
1836            "num_total_results": 2,
1837            "playlist_clips": [
1838                {"clip": {
1839                    "id": "a", "title": "A", "status": "complete",
1840                    "metadata": {"duration": 60.0, "type": "gen"}
1841                }},
1842                {"clip": {
1843                    "title": "No Id", "status": "complete",
1844                    "metadata": {"duration": 30.0, "type": "gen"}
1845                }}
1846            ]
1847        })
1848        .to_string();
1849        let empty_id = serde_json::json!({
1850            "num_total_results": 2,
1851            "playlist_clips": [
1852                {"clip": {
1853                    "id": "a", "title": "A", "status": "complete",
1854                    "metadata": {"duration": 60.0, "type": "gen"}
1855                }},
1856                {"clip": {
1857                    "id": "", "title": "Empty Id", "status": "complete",
1858                    "metadata": {"duration": 30.0, "type": "gen"}
1859                }}
1860            ]
1861        })
1862        .to_string();
1863        for body in [missing_id, empty_id] {
1864            let mut rules = auth_rules();
1865            rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1866            let http = MockHttp::new(rules);
1867            let client = authed_client(&http);
1868
1869            let (clips, complete) =
1870                pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1871            assert_eq!(clips.len(), 1, "the member with no id is dropped");
1872            assert!(
1873                !complete,
1874                "a dropped member disarms authority even when raw_len == total"
1875            );
1876        }
1877    }
1878
1879    #[test]
1880    fn get_playlist_clips_over_count_is_not_complete() {
1881        // total=2 but three raw members (one with an empty id): clips.len()==2
1882        // matches the total, yet raw_len==3 does not. The two-conjunct gate must
1883        // reject this; a mis-simplification to `clips.len() == total` would wrongly
1884        // arm authority here.
1885        let body = serde_json::json!({
1886            "num_total_results": 2,
1887            "playlist_clips": [
1888                {"clip": {
1889                    "id": "a", "title": "A", "status": "complete",
1890                    "metadata": {"duration": 60.0, "type": "gen"}
1891                }},
1892                {"clip": {
1893                    "id": "b", "title": "B", "status": "complete",
1894                    "metadata": {"duration": 30.0, "type": "gen"}
1895                }},
1896                {"clip": {
1897                    "id": "", "title": "Empty Id", "status": "complete",
1898                    "metadata": {"duration": 45.0, "type": "gen"}
1899                }}
1900            ]
1901        })
1902        .to_string();
1903        let mut rules = auth_rules();
1904        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1905        let http = MockHttp::new(rules);
1906        let client = authed_client(&http);
1907
1908        let (clips, complete) =
1909            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1910        assert_eq!(clips.len(), 2, "the empty-id member is dropped");
1911        assert!(
1912            !complete,
1913            "raw_len (3) diverging from the total (2) is not authoritative"
1914        );
1915    }
1916
1917    #[test]
1918    fn get_playlist_clips_ignores_song_count() {
1919        // The detail reports song_count=0 while num_total_results=1 for the same
1920        // playlist; completeness must trust num_total_results, so a single-member
1921        // page reads as complete instead of being compared against song_count.
1922        let body = serde_json::json!({
1923            "num_total_results": 1,
1924            "song_count": 0,
1925            "playlist_clips": [
1926                {"clip": {
1927                    "id": "only", "title": "Only", "status": "complete",
1928                    "metadata": {"duration": 60.0, "type": "gen"}
1929                }}
1930            ]
1931        })
1932        .to_string();
1933        let mut rules = auth_rules();
1934        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
1935        let http = MockHttp::new(rules);
1936        let client = authed_client(&http);
1937
1938        let (clips, complete) =
1939            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
1940        assert_eq!(clips.len(), 1);
1941        assert!(
1942            complete,
1943            "completeness uses num_total_results, not song_count"
1944        );
1945    }
1946
1947    #[test]
1948    fn get_playlists_num_clips_ignores_song_count() {
1949        // song_count is unreliable across endpoints (15 in the listing, 0 in the
1950        // detail), so num_clips must come from num_total_results, never song_count.
1951        let page1 = serde_json::json!({
1952            "playlists": [
1953                {"id": "pl1", "name": "Road Trip", "num_total_results": 15, "song_count": 0}
1954            ]
1955        })
1956        .to_string();
1957        let mut rules = auth_rules();
1958        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
1959        rules.push(Rule::new(
1960            "/api/playlist/me?page=2",
1961            200,
1962            r#"{"playlists": []}"#.to_string(),
1963        ));
1964        let http = MockHttp::new(rules);
1965        let client = authed_client(&http);
1966
1967        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1968        assert_eq!(
1969            playlists[0].num_clips, 15,
1970            "num_clips reads num_total_results, not song_count"
1971        );
1972    }
1973
1974    #[test]
1975    fn get_playlists_dedupes_a_page_ignoring_server() {
1976        // A server that ignores `page` returns the same non-empty body for every
1977        // page, so the empty-page terminator never fires and MAX_PAGES bounds the
1978        // loop. Dedupe-by-id keeps the result to the true unique set instead of
1979        // MAX_PAGES copies.
1980        let same_body = serde_json::json!({
1981            "playlists": [
1982                {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
1983                {"id": "pl2", "name": "Chill", "num_total_results": 7}
1984            ]
1985        })
1986        .to_string();
1987        let mut rules = auth_rules();
1988        rules.push(Rule::new("/api/playlist/me", 200, same_body));
1989        let http = MockHttp::new(rules);
1990        let client = authed_client(&http);
1991
1992        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
1993        assert_eq!(
1994            playlists.len(),
1995            2,
1996            "duplicates from a page-ignoring server are collapsed"
1997        );
1998        assert_eq!(playlists[0].id, "pl1");
1999        assert_eq!(playlists[1].id, "pl2");
2000    }
2001
2002    #[test]
2003    fn get_playlist_clips_preserves_array_order_over_created_at() {
2004        // relative_index ascends with array order while the wrapper created_at
2005        // values are non-monotonic. Members must stay in array order: the parser
2006        // never sorts by created_at (or any timestamp).
2007        let body = serde_json::json!({
2008            "num_total_results": 3,
2009            "playlist_clips": [
2010                {"clip": {
2011                    "id": "a", "title": "A", "status": "complete",
2012                    "metadata": {"duration": 60.0, "type": "gen"}
2013                }, "relative_index": 1.0, "created_at": "2026-06-08T00:00:00.000Z"},
2014                {"clip": {
2015                    "id": "b", "title": "B", "status": "complete",
2016                    "metadata": {"duration": 30.0, "type": "gen"}
2017                }, "relative_index": 2.0, "created_at": "2026-01-11T00:00:00.000Z"},
2018                {"clip": {
2019                    "id": "c", "title": "C", "status": "complete",
2020                    "metadata": {"duration": 45.0, "type": "gen"}
2021                }, "relative_index": 3.0, "created_at": "2026-05-15T00:00:00.000Z"}
2022            ]
2023        })
2024        .to_string();
2025        let mut rules = auth_rules();
2026        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
2027        let http = MockHttp::new(rules);
2028        let client = authed_client(&http);
2029
2030        let (clips, complete) =
2031            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
2032        assert_eq!(
2033            clips.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(),
2034            ["a", "b", "c"],
2035            "array order is preserved despite non-monotonic created_at"
2036        );
2037        assert!(complete, "three intact members equal the declared total");
2038    }
2039
2040    /// A stems page body: each stem is a full clip object whose title carries
2041    /// the label in a trailing parenthetical, as the live endpoint returns.
2042    fn stem_page(stems: &[(&str, &str, &str)]) -> String {
2043        let entries: Vec<Value> = stems
2044            .iter()
2045            .map(|(id, label, url)| {
2046                serde_json::json!({
2047                    "id": id,
2048                    "title": format!("My Song ({label})"),
2049                    "status": "complete",
2050                    "audio_url": url,
2051                })
2052            })
2053            .collect();
2054        serde_json::json!({ "stems": entries }).to_string()
2055    }
2056
2057    /// The page-count body for `GET /api/clip/{id}/stems/pages`.
2058    fn stem_pages(pages: u32) -> String {
2059        serde_json::json!({ "pages": pages }).to_string()
2060    }
2061
2062    #[test]
2063    fn list_stems_drains_all_declared_pages_and_is_authoritative() {
2064        // Two 0-indexed pages, both drained: the stems concatenate in order and
2065        // the listing is authoritative (it declared its pages and held stems).
2066        let http = ScriptedHttp::new()
2067            .with_auth()
2068            .route("stems/pages", Reply::json(&stem_pages(2)))
2069            .route(
2070                "stems?page=0",
2071                Reply::json(&stem_page(&[
2072                    ("s1", "Vocals", "https://cdn1.suno.ai/s1.mp3"),
2073                    ("s2", "Drums", "https://cdn1.suno.ai/s2.mp3"),
2074                ])),
2075            )
2076            .route(
2077                "stems?page=1",
2078                Reply::json(&stem_page(&[("s3", "Bass", "https://cdn1.suno.ai/s3.mp3")])),
2079            );
2080        let client = scripted_client(&http, RecordingClock::new());
2081
2082        let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
2083        assert_eq!(stems.len(), 3);
2084        assert_eq!(stems[0].id, "s1");
2085        assert_eq!(stems[0].label, "Vocals");
2086        assert_eq!(stems[0].url, "https://cdn1.suno.ai/s1.mp3");
2087        assert_eq!(stems[2].label, "Bass");
2088        assert!(
2089            complete,
2090            "a fully drained listing that returned stems is authoritative"
2091        );
2092    }
2093
2094    #[test]
2095    fn list_stems_zero_pages_is_indeterminate_never_empty() {
2096        // A clip with no stems answers `{"pages": 0}`. That must NOT be read as an
2097        // authoritative empty set, or it could delete local stems.
2098        let http = ScriptedHttp::new()
2099            .with_auth()
2100            .route("stems/pages", Reply::json(&stem_pages(0)));
2101        let client = scripted_client(&http, RecordingClock::new());
2102
2103        let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
2104        assert!(stems.is_empty());
2105        assert!(
2106            !complete,
2107            "an empty listing is indeterminate, so existing stems are kept"
2108        );
2109    }
2110
2111    #[test]
2112    fn list_stems_missing_page_count_is_indeterminate() {
2113        // A `400`/`404` on the page-count endpoint (Suno's "no stems" answer) is
2114        // indeterminate, never an authoritative empty set.
2115        for status in [400u16, 404] {
2116            let http = ScriptedHttp::new()
2117                .with_auth()
2118                .route("stems/pages", Reply::status(status));
2119            let client = scripted_client(&http, RecordingClock::new());
2120            let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
2121            assert!(stems.is_empty(), "status {status}");
2122            assert!(!complete, "status {status} is indeterminate, not empty");
2123        }
2124    }
2125
2126    #[test]
2127    fn stem_page_count_5xx_with_invalid_page_body_is_not_no_stems() {
2128        // A `5xx` whose body happens to contain "Invalid page" must NOT be
2129        // classified as "no stems": body-text matching would misclassify it.
2130        // Only a genuine `400` status triggers the no-stems path.
2131        let http = ScriptedHttp::new()
2132            .with_auth()
2133            .route("stems/pages", Reply::with_body(500, "Invalid page"));
2134        let client = scripted_client(&http, RecordingClock::new());
2135
2136        let result = pollster::block_on(client.list_stems(&http, "clip1"));
2137        assert!(
2138            result.is_err(),
2139            "a 5xx is a transient error, never 'no stems'"
2140        );
2141    }
2142
2143    #[test]
2144    fn list_stems_page_error_mid_enumeration_propagates() {
2145        // A transient 5xx on a page mid-drain is indeterminate, not an end: it
2146        // surfaces as an error rather than a (partial) authoritative set, so the
2147        // caller keeps existing stems.
2148        let http = ScriptedHttp::new()
2149            .with_auth()
2150            .route("stems/pages", Reply::json(&stem_pages(2)))
2151            .route(
2152                "stems?page=0",
2153                Reply::json(&stem_page(&[(
2154                    "s1",
2155                    "Vocals",
2156                    "https://cdn1.suno.ai/s1.mp3",
2157                )])),
2158            )
2159            .route("stems?page=1", Reply::status(500));
2160        let client = scripted_client(&http, RecordingClock::new());
2161
2162        let result = pollster::block_on(client.list_stems(&http, "clip1"));
2163        assert!(result.is_err(), "a 5xx page is not a clean drain");
2164    }
2165
2166    #[test]
2167    fn list_stems_over_max_pages_is_truncated_never_authoritative() {
2168        // A clip that declares more pages than the `MAX_PAGES` cap can only be
2169        // drained partially, so even though the fetched pages hold stems the
2170        // listing is TRUNCATED and must not be authoritative: its un-fetched
2171        // stems on pages beyond the cap would otherwise be delete-reconciled.
2172        let http = ScriptedHttp::new()
2173            .with_auth()
2174            .route("stems/pages", Reply::json(&stem_pages(MAX_PAGES + 1)))
2175            .route(
2176                "stems?page=",
2177                Reply::json(&stem_page(&[(
2178                    "s1",
2179                    "Vocals",
2180                    "https://cdn1.suno.ai/s1.mp3",
2181                )])),
2182            );
2183        let client = scripted_client(&http, RecordingClock::new());
2184
2185        let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
2186        assert!(!stems.is_empty(), "the fetched pages still yield stems");
2187        assert!(
2188            !complete,
2189            "a listing declaring more than MAX_PAGES is truncated, never authoritative"
2190        );
2191    }
2192
2193    #[test]
2194    fn parse_stems_page_maps_full_clips_and_skips_idless() {
2195        // A stem is a full clip: id, label from the title parenthetical, and the
2196        // public CDN MP3 url.
2197        let page = stem_page(&[("x", "Backing Vocals", "https://cdn1.suno.ai/x.mp3")]);
2198        let stems = parse_stems_page(page.as_bytes());
2199        assert_eq!(stems.len(), 1);
2200        assert_eq!(stems[0].id, "x");
2201        assert_eq!(stems[0].label, "Backing Vocals");
2202        assert_eq!(stems[0].url, "https://cdn1.suno.ai/x.mp3");
2203        // An entry with no id cannot be keyed or WAV-rendered and is dropped.
2204        let no_id = br#"{"stems": [{"title": "Ghost (Vocals)", "audio_url": "https://cdn1.suno.ai/g.mp3"}]}"#;
2205        assert!(parse_stems_page(no_id).is_empty());
2206        // A stem with an id but no audio_url still resolves a deterministic CDN
2207        // url from its id, so it remains downloadable.
2208        let no_url = br#"{"stems": [{"id": "y", "title": "Song (Bass)"}]}"#;
2209        let recovered = parse_stems_page(no_url);
2210        assert_eq!(recovered.len(), 1);
2211        assert_eq!(recovered[0].url, "https://cdn1.suno.ai/y.mp3");
2212        // Malformed JSON never panics; it yields no stems.
2213        assert!(parse_stems_page(b"not json").is_empty());
2214    }
2215
2216    #[test]
2217    fn parse_stem_page_count_reads_pages_field() {
2218        assert_eq!(parse_stem_page_count(br#"{"pages": 12}"#), 12);
2219        assert_eq!(parse_stem_page_count(br#"{"pages": 0}"#), 0);
2220        // Missing, negative, or non-numeric pages read as 0 (indeterminate).
2221        assert_eq!(parse_stem_page_count(br#"{}"#), 0);
2222        assert_eq!(parse_stem_page_count(br#"{"pages": -1}"#), 0);
2223        assert_eq!(parse_stem_page_count(b"not json"), 0);
2224    }
2225
2226    #[test]
2227    fn stem_label_from_title_extracts_trailing_parenthetical() {
2228        assert_eq!(stem_label_from_title("My Song (Vocals)"), "Vocals");
2229        assert_eq!(
2230            stem_label_from_title("A (b) Song (Backing Vocals)"),
2231            "Backing Vocals"
2232        );
2233        assert_eq!(stem_label_from_title("My Song (Drums) "), "Drums");
2234        // No parenthetical: empty, so the caller falls back to the stem id.
2235        assert_eq!(stem_label_from_title("My Song"), "");
2236        assert_eq!(stem_label_from_title(""), "");
2237    }
2238
2239    #[test]
2240    fn post_allow_list_permits_only_feed_and_wav_render() {
2241        assert!(post_path_allowed(FEED_V3_PATH));
2242        assert!(post_path_allowed("/api/gen/abc123/convert_wav/"));
2243        // No generation endpoint is on the list.
2244        assert!(!post_path_allowed("/api/gen/abc123/stem_task"));
2245        assert!(!post_path_allowed("/api/gen/abc123/separate"));
2246        // Path traversal or extra segments can't smuggle a match.
2247        assert!(!post_path_allowed("/api/gen/a/../evil/convert_wav/"));
2248        assert!(!post_path_allowed("/api/gen/a/b/convert_wav/"));
2249        // The stems endpoints are GET-only and never on the POST allow-list.
2250        assert!(!post_path_allowed("/api/clip/x/stems/pages"));
2251        assert!(!post_path_allowed("/api/clip/x/stems?page=0"));
2252    }
2253
2254    #[test]
2255    fn api_request_refuses_a_post_off_the_allow_list() {
2256        // The single POST chokepoint rejects an off-list POST before the wire, so
2257        // a credit-spending endpoint can never be reached by accident.
2258        let http = MockHttp::new(auth_rules());
2259        let client = authed_client(&http);
2260        let err = pollster::block_on(client.api_request(
2261            &http,
2262            Method::Post,
2263            "/api/gen/x/stem_task",
2264            b"{}".to_vec(),
2265        ))
2266        .unwrap_err();
2267        assert!(matches!(err, Error::Refused(_)));
2268    }
2269}