Skip to main content

suno_core/
client.rs

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