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, HashMap};
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, GET_SONGS_BY_IDS_PATH, GET_SONGS_CHUNK, MAX_PAGES, PLAYLIST_ME_PATH,
16    PLAYLIST_PATH, SUNO_API_BASE_URL,
17};
18use crate::error::{Error, Result};
19use crate::http::{Http, HttpRequest, Method};
20use crate::is_downloadable;
21use crate::limiter::{AdaptiveLimiter, retry_after_delay};
22use crate::lyrics::AlignedLyrics;
23use crate::model::Clip;
24
25/// One of the account's own playlists, as listed by `/api/playlist/me`.
26///
27/// Carries only what playlist reconciliation needs: the stable id (the state
28/// key), the display name (drives the `.m3u8` file name and `#PLAYLIST` line),
29/// and the member count for reporting. The ordered members are fetched
30/// separately with [`SunoClient::get_playlist_clips`].
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Playlist {
33    /// The playlist's stable Suno id.
34    pub id: String,
35    /// The playlist's display name.
36    pub name: String,
37    /// The number of clips Suno reports in the playlist.
38    pub num_clips: u64,
39}
40
41/// The authenticated account's billing snapshot: credits, quota, account
42/// status, plan identity, and entitlements.
43///
44/// Every field is optional so a drifting payload never fails the parse; an
45/// absent field reads as "unknown", not zero. Numbers are signed because the
46/// API returns negatives (e.g. the `-1` sentinel), and `features` is a plain
47/// string set rather than an enum so new entitlement flags surface without a
48/// code change.
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub struct BillingInfo {
51    /// Credits remaining in the current billing state.
52    pub total_credits_left: Option<i64>,
53    /// Monthly credit allotment (the quota denominator).
54    pub monthly_limit: Option<i64>,
55    /// Credits consumed this period (the quota numerator).
56    pub monthly_usage: Option<i64>,
57    /// Add-on, non-monthly credit balance.
58    pub credits: Option<i64>,
59    /// Billing period unit, e.g. `"month"`.
60    pub period: Option<String>,
61    /// Current period end (ISO8601), when usage resets.
62    pub period_end: Option<String>,
63    /// Next renewal (ISO8601).
64    pub renews_on: Option<String>,
65    /// Whether the subscription is active.
66    pub is_active: Option<bool>,
67    /// Whether the subscription is paused (paused subs stop refreshing credits).
68    pub is_paused: Option<bool>,
69    /// Whether payment is failing (credits may stop refreshing).
70    pub is_past_due: Option<bool>,
71    /// Whether the subscription is gifted.
72    pub is_gifted: Option<bool>,
73    /// Subscription platform, e.g. `"stripe"`.
74    pub subscription_platform: Option<String>,
75    /// Stable machine key for the plan tier, e.g. `"pro"`.
76    pub plan_key: Option<String>,
77    /// Human plan label, e.g. `"Pro Plan"`.
78    pub plan_name: Option<String>,
79    /// Plan tier rank (free 0, pro 10, premier 30).
80    pub plan_level: Option<i64>,
81    /// Entitlement flags, the union of `accessible_features[].name` and
82    /// `plan.usage_plan_features[].name`.
83    pub features: BTreeSet<String>,
84}
85
86impl BillingInfo {
87    /// Whether the account is entitled to the named feature.
88    pub fn has_feature(&self, name: &str) -> bool {
89        self.features.contains(name)
90    }
91
92    /// Whether the account may separate stems.
93    pub fn can_get_stems(&self) -> bool {
94        self.has_feature("get_stems")
95    }
96
97    /// Whether the account may convert audio to lossless.
98    pub fn can_convert_audio(&self) -> bool {
99        self.has_feature("convert_audio")
100    }
101}
102
103/// One separated stem of a clip, as listed by the free, read-only stems
104/// endpoint.
105///
106/// A stem is itself a full clip object: the listing returns the same shape as
107/// the library feed, so each stem carries its own clip `id`, a `title` whose
108/// trailing parenthetical is the stem label (e.g. `"My Song (Vocals)"`), a
109/// `status`, and a public `audio_url` on `cdn1.suno.ai` that downloads free and
110/// unauthenticated. Listing and downloading stems never spends credits or
111/// triggers separation.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Stem {
114    /// The stem's own server clip id. Used both as the stable per-stem key and
115    /// to render the stem's lossless WAV through the free `convert_wav` flow.
116    pub id: String,
117    /// The stem label, preferring the structured `metadata.stem_type_group_name`
118    /// (normalised, e.g. `Backing_Vocals` -> `Backing Vocals`) and falling back
119    /// to the trailing parenthetical of the stem clip's title. May be blank when
120    /// neither is present, so it is never used alone as a key or name.
121    pub label: String,
122    /// The public CDN MP3 URL the stem downloads from (a plain GET; free).
123    pub url: String,
124}
125
126/// A client for the Suno library API, owning the account's [`ClerkAuth`].
127///
128/// The [`Clock`] is held so [`api_request`](Self::api_request) can back off
129/// through the port on a `429` or transient failure — the engine still sleeps
130/// nowhere itself. The [`AdaptiveLimiter`] paces reactively: an unthrottled
131/// listing waits nowhere, and only after a `429` does it space requests out,
132/// halving the rate and ramping it back after a run of clean successes so pacing
133/// tracks Suno's real limit rather than a fixed constant.
134pub struct SunoClient<C> {
135    auth: ClerkAuth,
136    clock: C,
137    limiter: Mutex<AdaptiveLimiter>,
138}
139
140impl<C: Clock> SunoClient<C> {
141    /// Create a client from a fresh or already-authenticated [`ClerkAuth`].
142    pub fn new(auth: ClerkAuth, clock: C) -> Self {
143        Self {
144            auth,
145            clock,
146            limiter: Mutex::new(AdaptiveLimiter::new(FEED_INITIAL_RATE)),
147        }
148    }
149
150    /// Borrow the underlying authenticator.
151    pub fn auth(&self) -> &ClerkAuth {
152        &self.auth
153    }
154
155    /// The adaptive limiter's current requests-per-second rate, for tests that
156    /// assert the limiter still records success and `429` correctly (including
157    /// under concurrent WAV-render calls serialised through the executor).
158    #[cfg(test)]
159    pub(crate) fn limiter_rate(&self) -> f64 {
160        self.limiter.lock().unwrap().rate()
161    }
162
163    /// List clips across the whole library, or only liked clips.
164    ///
165    /// Walks the cursor-paginated `POST /api/feed/v3` feed, following
166    /// `next_cursor` until the server reports the end. Once `limit` clips have
167    /// been collected it stops at the next page boundary and truncates to
168    /// `limit`. Paging is hard-capped at [`MAX_PAGES`] so a runaway
169    /// `has_more` can never loop forever. When `liked` is set the feed filter
170    /// scopes to liked clips (`liked: "True"`).
171    ///
172    /// Returns the clips paired with a `complete` flag that is `true` only when
173    /// paging ended because the server reported `has_more == false` (the feed
174    /// fully drained). A missing `has_more`, a `has_more == true` page with no
175    /// usable `next_cursor`, a `limit` stop, exhausting [`MAX_PAGES`], or any
176    /// transport error all yield `false` (or propagate) so the caller can refuse
177    /// to treat a truncated listing as authoritative for deletion.
178    ///
179    /// A third `any_filtered` flag is `true` when any listed clip was dropped by
180    /// the downloadable filter on any page, so the caller can refuse deletion
181    /// authority for a listing that may have hidden a manifest-tracked clip
182    /// (#248), exactly as the playlist path already does.
183    pub async fn list_clips(
184        &self,
185        http: &impl Http,
186        liked: bool,
187        limit: Option<usize>,
188    ) -> Result<(Vec<Clip>, bool, bool)> {
189        let mut clips = Vec::new();
190        let mut cursor: Option<String> = None;
191        let mut complete = false;
192        let mut any_filtered = false;
193        for _ in 0..MAX_PAGES {
194            let body = feed_v3_body(liked, cursor.as_deref());
195            let response = self
196                .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
197                .await?;
198            let page = parse_feed_v3(&response)?;
199            clips.extend(page.clips);
200            any_filtered |= page.any_filtered;
201            match page.has_more {
202                Some(false) => {
203                    complete = true;
204                    break;
205                }
206                Some(true) => match page.next_cursor {
207                    Some(next) => cursor = Some(next),
208                    None => break,
209                },
210                None => break,
211            }
212            if limit.is_some_and(|n| clips.len() >= n) {
213                break;
214            }
215        }
216        if let Some(n) = limit {
217            clips.truncate(n);
218        }
219        Ok((clips, complete, any_filtered))
220    }
221
222    /// Fetch one clip by ID.
223    ///
224    /// Tries the dedicated `/api/clip/{id}` endpoint first, then falls back to
225    /// scanning the library feed if that endpoint yields no matching clip.
226    pub async fn get_clip(&self, http: &impl Http, id: &str) -> Result<Clip> {
227        if let Some(clip) = self.try_get_clip(http, id).await? {
228            return Ok(clip);
229        }
230        self.find_in_feed(http, id).await
231    }
232
233    /// Ask Suno to render a clip to lossless WAV (server-side, asynchronous).
234    pub async fn request_wav(&self, http: &impl Http, id: &str) -> Result<()> {
235        let path = format!("/api/gen/{id}/convert_wav/");
236        self.api_request(http, Method::Post, &path, Vec::new())
237            .await?;
238        Ok(())
239    }
240
241    /// Read the rendered WAV URL for a clip, or `None` while it is not ready.
242    ///
243    /// A `404` maps to `None` (the render is absent, not yet requested, or the
244    /// endpoint has moved), symmetric with [`aligned_lyrics`](Self::aligned_lyrics)
245    /// so an unrendered clip is "no WAV yet" rather than a run-aborting error.
246    /// Like [`request_wav`](Self::request_wav) it skips the shared retry: the
247    /// caller's poll loop owns that budget.
248    pub async fn wav_url(&self, http: &impl Http, id: &str) -> Result<Option<String>> {
249        let path = format!("/api/gen/{id}/wav_file/");
250        let body = match self.api_get(http, &path).await {
251            Ok(body) => body,
252            Err(Error::NotFound(_)) => return Ok(None),
253            Err(err) => return Err(err),
254        };
255        let data: Value = serde_json::from_slice(&body)
256            .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
257        Ok(data
258            .get("wav_file_url")
259            .and_then(Value::as_str)
260            .filter(|url| !url.is_empty())
261            .map(str::to_string))
262    }
263
264    /// Fetch a clip's word- and line-level aligned (synced) lyrics.
265    ///
266    /// `GET /api/gen/{id}/aligned_lyrics/v2/` (the trailing slash is required) on
267    /// the studio-api host, authenticated with the same JWT as every other
268    /// library read. The `v2` shape carries both a flat word-level list and a
269    /// line-level list with section labels and nested per-word timing (see
270    /// [`AlignedLyrics`]).
271    ///
272    /// An instrumental or un-alignable clip returns `200` with empty arrays,
273    /// which maps to an empty [`AlignedLyrics`]; a `404` (no alignment for the
274    /// clip) is treated the same way, so an absent endpoint is "no synced
275    /// lyrics" rather than a run failure — the caller then writes no synced
276    /// artefact, exactly as an empty cover URL writes no cover. Rides the
277    /// adaptive rate limiter like the other reads.
278    pub async fn aligned_lyrics(&self, http: &impl Http, id: &str) -> Result<AlignedLyrics> {
279        let path = format!("/api/gen/{id}/aligned_lyrics/v2/");
280        match self.api_get_retrying(http, &path).await {
281            Ok(body) => Ok(AlignedLyrics::from_bytes(&body)),
282            Err(Error::NotFound(_)) => Ok(AlignedLyrics::default()),
283            Err(err) => Err(err),
284        }
285    }
286
287    /// Fetch specific clips by id, batch-first with a per-id fallback.
288    ///
289    /// Used by lineage resolution to gap-fill ancestors that are absent from a
290    /// normal listing, including trashed ones. Ids are fetched in a single
291    /// batch via [`get_songs_by_ids`](Self::get_songs_by_ids)
292    /// (`GET /api/clips/get_songs_by_ids`), which cuts the round-trips and `429`s
293    /// of one request per id. Any ids the batch does not return (individually
294    /// trashed or absent, exactly as a `/api/clip/{id}` `404` today, or in a
295    /// chunk the batch endpoint could not serve) then fall back to one
296    /// `GET /api/clip/{id}` each, with bounded `concurrency`, attempted exactly
297    /// once, and a `404` there is skipped so the caller can fall back to the
298    /// parent endpoint. A `429` while batching propagates rather than fanning
299    /// out into per-id requests.
300    ///
301    /// Unlike [`list_clips`](Self::list_clips), no downloadability filter is
302    /// applied: an ancestor may itself be an infill or context-window artefact
303    /// that the lineage walk must still traverse. Clips returned here are
304    /// ancestors for resolution only and must never be treated as download
305    /// candidates. Ids are deduplicated in order and the result preserves that
306    /// de-duplicated input order, matched by id (never by response position).
307    /// The signature is unchanged so [`gap_fill`](crate::lineage) is unaffected.
308    pub async fn get_clips_by_ids(
309        &self,
310        http: &impl Http,
311        ids: &[&str],
312        concurrency: usize,
313    ) -> Result<Vec<Clip>> {
314        let ordered = dedup_nonempty(ids);
315        let mut found: HashMap<&str, Clip> = self
316            .get_songs_by_ids(http, &ordered)
317            .await?
318            .into_iter()
319            .filter_map(|clip| {
320                ordered
321                    .iter()
322                    .find(|id| **id == clip.id)
323                    .map(|id| (*id, clip))
324            })
325            .collect();
326        let omitted: Vec<&str> = ordered
327            .iter()
328            .copied()
329            .filter(|id| !found.contains_key(id))
330            .collect();
331        if !omitted.is_empty() {
332            for clip in self
333                .fetch_clips_individually(http, &omitted, concurrency)
334                .await?
335            {
336                if let Some(id) = ordered.iter().copied().find(|id| *id == clip.id) {
337                    found.insert(id, clip);
338                }
339            }
340        }
341        Ok(ordered.iter().filter_map(|id| found.remove(id)).collect())
342    }
343
344    /// Batch-fetch clips by id via `GET /api/clips/get_songs_by_ids?ids=…&ids=…`.
345    ///
346    /// This is the pure batch primitive: the deduplicated ids are split into
347    /// chunks of [`GET_SONGS_CHUNK`], each requested with repeated `ids=` params,
348    /// and the `{"clips":[…]}` body is parsed defensively and matched back to the
349    /// requested ids by id, so the result preserves the de-duplicated input order
350    /// regardless of the server's ordering and drops any clip that was not asked
351    /// for. Ids the batch does not return (trashed, absent, or in a chunk the
352    /// endpoint could not serve) are simply left out; filling them is the
353    /// caller's job (see [`get_clips_by_ids`](Self::get_clips_by_ids)).
354    ///
355    /// The batch endpoint is undocumented and may be unavailable. A chunk that
356    /// the endpoint cannot serve (a `404`, a `400`, a `5xx`, a transport failure,
357    /// or a body that is not `{"clips":[…]}`) yields nothing for that chunk
358    /// rather than erroring, so an outage or reshape degrades rather than breaks
359    /// (the decoupling rule) and the caller's per-id fallback recovers those ids
360    /// exactly once. A `429`, by contrast, rides the retry inside
361    /// [`api_get_retrying`](Self::api_get_retrying) and, once exhausted,
362    /// propagates rather than letting a burst of per-id requests deepen the
363    /// throttling; an auth failure likewise propagates rather than being masked.
364    pub async fn get_songs_by_ids(&self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
365        let ordered = dedup_nonempty(ids);
366        let mut found: HashMap<&str, Clip> = HashMap::new();
367        for chunk in ordered.chunks(GET_SONGS_CHUNK) {
368            let query = chunk
369                .iter()
370                .map(|id| format!("ids={id}"))
371                .collect::<Vec<_>>()
372                .join("&");
373            let path = format!("{GET_SONGS_BY_IDS_PATH}?{query}");
374            let clips = match self.api_get_retrying(http, &path).await {
375                Ok(body) => parse_songs_batch(&body).unwrap_or_default(),
376                Err(err @ (Error::RateLimited { .. } | Error::Auth(_))) => return Err(err),
377                Err(_) => Vec::new(),
378            };
379            for clip in clips {
380                if let Some(id) = chunk.iter().copied().find(|id| *id == clip.id) {
381                    found.insert(id, clip);
382                }
383            }
384        }
385        Ok(ordered.iter().filter_map(|id| found.remove(id)).collect())
386    }
387
388    /// Fetch clips one `GET /api/clip/{id}` per id, with bounded concurrency.
389    ///
390    /// The per-id fallback used by [`get_clips_by_ids`](Self::get_clips_by_ids)
391    /// for any ids the batch did not return, whether individually omitted or in a
392    /// whole chunk the batch endpoint could not serve. `/api/clip/{id}` returns
393    /// any clip, trashed or artefact, with the full field set and no
394    /// downloadability filter. An id that `404`s is skipped; the input order is
395    /// preserved.
396    async fn fetch_clips_individually(
397        &self,
398        http: &impl Http,
399        ids: &[&str],
400        concurrency: usize,
401    ) -> Result<Vec<Clip>> {
402        let limit = concurrency.max(1);
403        let fetched = stream::iter(ids.iter().copied())
404            .map(|id| async move {
405                let path = format!("/api/clip/{id}");
406                match self.api_get_retrying(http, &path).await {
407                    Ok(body) => Ok(parse_clip(&body)),
408                    Err(Error::NotFound(_)) => Ok(None),
409                    Err(err) => Err(err),
410                }
411            })
412            .buffered(limit)
413            .collect::<Vec<_>>()
414            .await;
415        let mut clips = Vec::new();
416        for item in fetched {
417            if let Some(clip) = item? {
418                clips.push(clip);
419            }
420        }
421        Ok(clips)
422    }
423
424    /// Fetch a clip's immediate parent via the dedicated parent endpoint.
425    ///
426    /// Returns the parent clip, or `None` when the clip is a root. A root's
427    /// parent is reported as HTTP `200` with a bodiless clip that carries no
428    /// `id` (e.g. `{"is_public": false}`), not a `404`: [`parse_clip`] requires
429    /// a non-empty id, so that root shape maps to `Ok(None)` here. The `404`
430    /// arm is kept as a belt-and-braces fallback for the alternative "no parent"
431    /// encoding. Any other failure, including a transient `5xx`, propagates as
432    /// an error rather than being mistaken for a root.
433    pub async fn get_clip_parent(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
434        let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
435        match self.api_get_retrying(http, &path).await {
436            // A root replies 200 with no id; parse_clip gates on a non-empty id
437            // and yields None, so a root never looks like a fetched parent.
438            Ok(body) => Ok(parse_clip(&body)),
439            Err(Error::NotFound(_)) => Ok(None),
440            Err(err) => Err(err),
441        }
442    }
443
444    /// List the account's own playlists, paging `/api/playlist/me`.
445    ///
446    /// Trashed and share-list playlists are excluded by query, so the result is
447    /// the account's authoritative own set. Paging stops on the first empty page
448    /// and is hard-capped at [`MAX_PAGES`] so a server that ignores the page
449    /// parameter cannot loop forever. Only entries with a non-empty id are kept,
450    /// and accumulated entries are de-duplicated by id so a server that ignores
451    /// the page parameter and repeats a body cannot inflate the set.
452    ///
453    /// A hard failure propagates as an error; the caller treats that as "the
454    /// playlist listing did not fully enumerate" and refuses every playlist
455    /// deletion this run, so a dropped fetch can never remove a `.m3u8`.
456    pub async fn get_playlists(&self, http: &impl Http) -> Result<Vec<Playlist>> {
457        let mut playlists = Vec::new();
458        let mut seen = BTreeSet::new();
459        for page in 1..=MAX_PAGES {
460            let path =
461                format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
462            let body = self.api_get_retrying(http, &path).await?;
463            let page_playlists = parse_playlists(&body)?;
464            if page_playlists.is_empty() {
465                break;
466            }
467            for playlist in page_playlists {
468                if seen.insert(playlist.id.clone()) {
469                    playlists.push(playlist);
470                }
471            }
472        }
473        Ok(playlists)
474    }
475
476    /// Fetch one playlist's clips in Suno order via `/api/playlist/{id}/`.
477    ///
478    /// The response's `playlist_clips[]` is already ordered and trashed members
479    /// are excluded by Suno, so the order is preserved exactly and no
480    /// downloadability filter is applied: a playlist may legitimately contain any
481    /// clip. Each entry's `clip` object is mapped (falling back to the entry
482    /// itself), and only clips with a non-empty id are kept.
483    ///
484    /// The returned `bool` is a completeness signal for deletion authority: the
485    /// endpoint reports `num_total_results` (the playlist's full member count)
486    /// alongside `playlist_clips[]`, so `true` means every member came back on
487    /// this single page intact (`num_total_results` present, equal to the raw
488    /// count, and no member dropped for a missing/empty id). A short page, or one
489    /// missing a member's id, returns `false`, so a Mirror playlist area under
490    /// `library = "off"` is never treated as authoritative unless its whole
491    /// member set was seen (D5).
492    pub async fn get_playlist_clips(
493        &self,
494        http: &impl Http,
495        id: &str,
496    ) -> Result<(Vec<Clip>, bool)> {
497        let path = format!("{PLAYLIST_PATH}{id}/");
498        let body = self.api_get_retrying(http, &path).await?;
499        parse_playlist_clips(&body)
500    }
501
502    /// Read the authenticated account's billing information.
503    pub async fn get_billing_info(&self, http: &impl Http) -> Result<BillingInfo> {
504        let body = self.api_get_retrying(http, BILLING_INFO_PATH).await?;
505        parse_billing_info(&body)
506    }
507
508    /// List a clip's already-separated stems (free, read-only).
509    ///
510    /// Uses the live stems shape: first `GET /api/clip/{id}/stems/pages` for the
511    /// page count (`{"pages": N}`), then `GET /api/clip/{id}/stems?page=P` for
512    /// each `P` in `0..N` (the pages are 0-indexed), whose body is
513    /// `{"stems": [<clip>, ...]}` where each stem is a full clip object. Every
514    /// request rides the shared limiter and retry. This endpoint only reads: it
515    /// never spends credits and never triggers separation, so it is safe on the
516    /// bulk mirror path. The caller must only invoke it when the clip's
517    /// `has_stem` is true.
518    ///
519    /// Returns the collected stems paired with a `complete` flag that is `true`
520    /// only when the listing was fully and authoritatively enumerated: the page
521    /// count came back and every one of its pages drained, AFTER at least one
522    /// stem was seen. This encodes the deletion-safety invariant: an empty
523    /// listing (`pages == 0`, or a `400`/`404` on the page-count endpoint, which
524    /// Suno returns for a clip with zero stems), a transport failure, or a
525    /// partial drain (a page error mid-enumeration surfaces as `Err`) all yield a
526    /// non-authoritative result, so the caller KEEPS any existing local stems and
527    /// never reads the absence as "no stems". A clip that declares more than
528    /// [`MAX_PAGES`] pages is likewise a truncated listing and never authoritative.
529    /// A stem is only ever removed from an authoritative (`complete`) listing that
530    /// omits it, or when its owning clip's audio is deleted.
531    pub async fn list_stems(&self, http: &impl Http, clip_id: &str) -> Result<(Vec<Stem>, bool)> {
532        let declared = self.stem_page_count(http, clip_id).await?;
533        // Zero pages (or no page count) is Suno's "this clip has no stems"
534        // answer: indeterminate for deletion, never an authoritative empty.
535        if declared == 0 {
536            return Ok((Vec::new(), false));
537        }
538        let pages = declared.min(MAX_PAGES);
539        let mut stems: Vec<Stem> = Vec::new();
540        for page in 0..pages {
541            // Pages are 0-indexed (0..N-1); note the path has no trailing slash
542            // before the query, distinguishing it from `.../stems/pages`.
543            let path = format!("/api/clip/{clip_id}/stems?page={page}");
544            // A page error mid-enumeration is indeterminate, not a clean end:
545            // surface it so the caller keeps existing stems rather than reading a
546            // partial drain as authoritative and removing stems.
547            let body = self.api_get_retrying(http, &path).await?;
548            stems.extend(parse_stems_page(&body));
549        }
550        dedupe_stems(&mut stems);
551        // Authoritative only when the whole declared page set actually drained
552        // and it held stems: an all-empty listing is never "no stems", and a
553        // clip declaring more than the `MAX_PAGES` cap is a truncated listing,
554        // never authoritative, so its un-fetched stems are kept (mirroring the
555        // feed's `list_clips` cap handling).
556        let complete = !stems.is_empty() && declared <= MAX_PAGES;
557        Ok((stems, complete))
558    }
559
560    /// Read the stems page count for a clip from `GET /api/clip/{id}/stems/pages`
561    /// (`{"pages": N}`).
562    ///
563    /// A clip with no stems answers `400`/`404` here; both mean "no stems" and
564    /// map to `0` (indeterminate, never an authoritative empty set), while any
565    /// other error (a transient `5xx`, a transport failure) propagates so the
566    /// caller treats the stems as unknown and keeps them.
567    async fn stem_page_count(&self, http: &impl Http, clip_id: &str) -> Result<u32> {
568        let path = format!("/api/clip/{clip_id}/stems/pages");
569        match self.api_get_retrying(http, &path).await {
570            Ok(body) => Ok(parse_stem_page_count(&body)),
571            Err(err) if is_invalid_page_error(&err) => Ok(0),
572            Err(Error::NotFound(_)) => Ok(0),
573            Err(err) => Err(err),
574        }
575    }
576
577    /// Try the dedicated clip endpoint, returning `None` when it is missing or
578    /// returns a body that does not yield the requested clip.
579    async fn try_get_clip(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
580        let path = format!("/api/clip/{id}");
581        match self.api_get_retrying(http, &path).await {
582            Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
583            Err(Error::NotFound(_)) => Ok(None),
584            Err(err) => Err(err),
585        }
586    }
587
588    /// Locate a clip by scanning the library feed.
589    async fn find_in_feed(&self, http: &impl Http, id: &str) -> Result<Clip> {
590        let (clips, _complete, _) = self.list_clips(http, false, None).await?;
591        clips
592            .into_iter()
593            .find(|clip| clip.id == id)
594            .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
595    }
596
597    /// Perform an authenticated GET, refreshing the JWT once on a 401/403.
598    async fn api_get(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
599        self.api_request(http, Method::Get, path, Vec::new()).await
600    }
601
602    /// A retrying GET: [`api_send_retrying`](Self::api_send_retrying) with no body.
603    async fn api_get_retrying(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
604        self.api_send_retrying(http, Method::Get, path, Vec::new())
605            .await
606    }
607
608    /// Like [`api_request`](Self::api_request) but rides through Suno's rate
609    /// limiter, pacing each request to the adaptive rate and backing off through
610    /// the [`Clock`] on a `429` (honouring `Retry-After` when present, defaulting
611    /// to 5s and capped at 60s) or a transient connection failure, up to
612    /// [`API_MAX_RETRIES`] times. Each attempt reconstructs the full request
613    /// (method, path, and body), so a throttled feed page re-POSTs the same
614    /// cursor rather than skipping ahead.
615    ///
616    /// Pacing lives here, at the single per-request layer, rather than in any
617    /// paged walk, so it composes with whatever listing calls it: a page or a
618    /// cursor walk pace identically. The [`AdaptiveLimiter`] paces reactively:
619    /// an unthrottled walk waits nowhere, and only after the first `429` does it
620    /// reserve shared request slots so concurrent callers are spaced in aggregate
621    /// at `1/rate`, widening that spacing as the rate is halved again.
622    ///
623    /// The WAV render flow deliberately keeps to the plain [`api_get`](Self::api_get):
624    /// the executor owns that retry so its budget and poll interval stay in one
625    /// place. Library, playlist, and lineage reads use this so a full-library
626    /// walk is not aborted by a single throttled page.
627    async fn api_send_retrying(
628        &self,
629        http: &impl Http,
630        method: Method,
631        path: &str,
632        body: Vec<u8>,
633    ) -> Result<Vec<u8>> {
634        let pace = self.limiter.lock().unwrap().pace(Instant::now());
635        if !pace.is_zero() {
636            self.clock.sleep(pace).await;
637        }
638        let mut retries = 0;
639        loop {
640            match self.api_request(http, method, path, body.clone()).await {
641                Ok(response) => return Ok(response),
642                Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
643                    self.clock.sleep(retry_after_delay(retry_after)).await;
644                    retries += 1;
645                }
646                Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
647                    self.clock.sleep(backoff_delay(retries, None)).await;
648                    retries += 1;
649                }
650                Err(err) => return Err(err),
651            }
652        }
653    }
654
655    /// Perform an authenticated request, refreshing the JWT once on a 401/403.
656    ///
657    /// `body` is sent only by the adapter when non-empty, so a GET or a bodyless
658    /// POST reaches the network unchanged.
659    async fn api_request(
660        &self,
661        http: &impl Http,
662        method: Method,
663        path: &str,
664        body: Vec<u8>,
665    ) -> Result<Vec<u8>> {
666        // Crate-wide POST allow-list. Every mutating Suno API request funnels
667        // through here, so refusing any POST to a path outside the known-safe
668        // set means a destructive or credit-spending endpoint can never be sent,
669        // even by a future edit that forgets the invariant. GETs are free and
670        // unrestricted; only POSTs are gated.
671        if method == Method::Post && !post_path_allowed(path) {
672            return Err(Error::Refused(format!(
673                "POST to {path} is not on the allow-list"
674            )));
675        }
676        let url = format!("{SUNO_API_BASE_URL}{path}");
677        let mut auth_refreshed = false;
678        loop {
679            let jwt = self.auth.ensure_jwt(self.clock.now_unix(), http).await?;
680            let mut request = match method {
681                Method::Get => HttpRequest::get(url.clone()),
682                Method::Post => HttpRequest::post(url.clone(), body.clone()),
683            };
684            request
685                .headers
686                .push(("Authorization".to_string(), format!("Bearer {jwt}")));
687            let response = http
688                .send(request)
689                .await
690                .map_err(|err| Error::Connection(err.to_string()))?;
691            match response.status {
692                200..=299 => {
693                    self.limiter.lock().unwrap().on_success();
694                    return Ok(response.body);
695                }
696                401 | 403 if !auth_refreshed => {
697                    self.auth.invalidate_jwt();
698                    auth_refreshed = true;
699                }
700                401 | 403 => {
701                    return Err(Error::Auth(format!(
702                        "Suno API auth failed with status {}",
703                        response.status
704                    )));
705                }
706                429 => {
707                    self.limiter.lock().unwrap().on_rate_limit();
708                    return Err(Error::RateLimited {
709                        retry_after: retry_after(&response),
710                    });
711                }
712                400 => {
713                    let preview: String = String::from_utf8_lossy(&response.body)
714                        .chars()
715                        .take(200)
716                        .collect();
717                    return Err(Error::BadRequest(format!(
718                        "Suno API returned 400: {preview}"
719                    )));
720                }
721                404 => {
722                    return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
723                }
724                status => {
725                    let preview: String = String::from_utf8_lossy(&response.body)
726                        .chars()
727                        .take(200)
728                        .collect();
729                    return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
730                }
731            }
732        }
733    }
734}
735
736/// Unwrap a `{ "clip": {...} }` wrapper to the inner clip object, or return
737/// `value` unchanged when it carries no object `clip` key (it is already bare).
738fn unwrap_clip(value: &Value) -> &Value {
739    value
740        .get("clip")
741        .filter(|clip| clip.is_object())
742        .unwrap_or(value)
743}
744
745/// Whether a Suno API path may be the target of a POST (the crate-wide POST
746/// allow-list). Membership is deliberately narrow so a mutating request is only
747/// ever sent to a vetted endpoint:
748///
749/// - [`FEED_V3_PATH`] — the cursor-paginated library listing (a POST by design).
750/// - `…/convert_wav/` — the per-clip server-side lossless WAV render.
751///
752/// A GET is never gated (reads are free and non-mutating). Any credit-spending
753/// generation endpoint is deliberately absent here.
754fn post_path_allowed(path: &str) -> bool {
755    if path == FEED_V3_PATH {
756        return true;
757    }
758    // The per-clip WAV render: /api/gen/{id}/convert_wav/ with a single id.
759    if let Some(rest) = path.strip_prefix("/api/gen/")
760        && let Some(id) = rest.strip_suffix("/convert_wav/")
761    {
762        return is_single_id_segment(id);
763    }
764    false
765}
766
767/// Whether `segment` is a single, non-empty path id segment: no slash, no query,
768/// and no `..` traversal, so an allow-list match can never be smuggled past by a
769/// crafted path.
770fn is_single_id_segment(segment: &str) -> bool {
771    !segment.is_empty()
772        && !segment.contains('/')
773        && !segment.contains('?')
774        && !segment.contains("..")
775}
776
777/// Whether an error is Suno's "this clip has no stems" answer on the stems
778/// page-count endpoint: a `400` (it returns `400 "Invalid page number"` for a
779/// clip with zero stems). Distinguished from a transient `5xx` (also
780/// [`Error::Api`]) so a server error is never mistaken for "no stems".
781fn is_invalid_page_error(err: &Error) -> bool {
782    matches!(err, Error::BadRequest(_))
783}
784
785/// Parse the stems page count from `GET /api/clip/{id}/stems/pages`
786/// (`{"pages": N}`).
787///
788/// A missing, non-numeric, or negative `pages` reads as `0` (no stems), so a
789/// malformed body is treated as indeterminate rather than guessing a count.
790fn parse_stem_page_count(body: &[u8]) -> u32 {
791    serde_json::from_slice::<Value>(body)
792        .ok()
793        .and_then(|data| data.get("pages").and_then(Value::as_u64))
794        .and_then(|pages| u32::try_from(pages).ok())
795        .unwrap_or(0)
796}
797
798/// Parse one page of the stems listing (`{"stems": [<clip>, ...]}`) into
799/// [`Stem`]s.
800///
801/// Each stem is a full clip object, so it is mapped with [`Clip::from_json`]:
802/// the id is the stem clip id, the label is the trailing parenthetical of its
803/// title, and the download URL is its public CDN MP3. Only stems carrying both a
804/// non-empty id and URL are kept — a stem with no id cannot be WAV-rendered, and
805/// one with no URL cannot be mirrored. Malformed JSON yields no stems (never a
806/// panic), so a bad body is treated as an empty, non-authoritative page.
807fn parse_stems_page(body: &[u8]) -> Vec<Stem> {
808    let Ok(data) = serde_json::from_slice::<Value>(body) else {
809        return Vec::new();
810    };
811    let items = if let Some(array) = data.as_array() {
812        array.as_slice()
813    } else {
814        data.get("stems")
815            .and_then(Value::as_array)
816            .map(Vec::as_slice)
817            .unwrap_or(&[])
818    };
819    items
820        .iter()
821        .map(parse_stem)
822        .filter(|stem| !stem.id.is_empty() && !stem.url.is_empty())
823        .collect()
824}
825
826/// Map one raw stem clip element to a [`Stem`]: its clip id, its stem label,
827/// and its public CDN MP3 URL.
828fn parse_stem(raw: &Value) -> Stem {
829    let clip = Clip::from_json(raw);
830    Stem {
831        id: clip.id.clone(),
832        label: stem_label(&clip),
833        url: clip.mp3_url(),
834    }
835}
836
837/// The stem's label, preferring the structured `metadata.stem_type_group_name`
838/// (normalised from its underscore form, `Backing_Vocals` -> `Backing Vocals`)
839/// over the fragile trailing title parenthetical, and empty when neither is
840/// present so the caller falls back to the stem id for naming.
841fn stem_label(clip: &Clip) -> String {
842    let group = clip.stem_type_group_name.replace('_', " ");
843    let group = group.trim();
844    if !group.is_empty() {
845        return group.to_string();
846    }
847    stem_label_from_title(&clip.title)
848}
849
850/// The stem label carried in a stem clip's title: the text inside its trailing
851/// parenthetical (`"My Song (Backing Vocals)"` -> `Backing Vocals`). Returns an
852/// empty string when the title has no closing parenthetical, so the caller falls
853/// back to the stem id for naming.
854fn stem_label_from_title(title: &str) -> String {
855    let trimmed = title.trim_end();
856    let Some(before_close) = trimmed.strip_suffix(')') else {
857        return String::new();
858    };
859    match before_close.rfind('(') {
860        Some(open) => before_close[open + 1..].trim().to_string(),
861        None => String::new(),
862    }
863}
864
865/// Drop stems that repeat across pages, keeping the first occurrence of each
866/// download URL so a paged listing counts a stem once.
867fn dedupe_stems(stems: &mut Vec<Stem>) {
868    let mut seen = BTreeSet::new();
869    stems.retain(|stem| seen.insert(stem.url.clone()));
870}
871
872/// Parse a single-clip response body, accepting either a bare clip object or a
873/// `{"clip": {...}}` wrapper. Returns `None` when no clip id is present.
874fn parse_clip(body: &[u8]) -> Option<Clip> {
875    let data: Value = serde_json::from_slice(body).ok()?;
876    let raw = unwrap_clip(&data);
877    let has_id = raw
878        .get("id")
879        .and_then(Value::as_str)
880        .is_some_and(|id| !id.is_empty());
881    has_id.then(|| Clip::from_json(raw))
882}
883
884/// Deduplicate ids in first-seen order, dropping empties. Shared by the by-id
885/// fetch paths so the batch, the fallback, and the returned order all agree.
886fn dedup_nonempty<'a>(ids: &[&'a str]) -> Vec<&'a str> {
887    let mut seen: BTreeSet<&str> = BTreeSet::new();
888    ids.iter()
889        .copied()
890        .filter(|id| !id.is_empty() && seen.insert(id))
891        .collect()
892}
893
894/// Parse a `get_songs_by_ids` `{"clips":[…]}` body into clips with a non-empty
895/// id. Returns `None` when the body is not valid JSON or lacks a `clips` array,
896/// signalling the caller to fall back to per-id fetches. No downloadability
897/// filter is applied: these are lineage ancestors, which may be artefacts.
898fn parse_songs_batch(body: &[u8]) -> Option<Vec<Clip>> {
899    let data: Value = serde_json::from_slice(body).ok()?;
900    let clips = data.get("clips")?.as_array()?;
901    Some(
902        clips
903            .iter()
904            .map(Clip::from_json)
905            .filter(|clip| !clip.id.is_empty())
906            .collect(),
907    )
908}
909
910/// Parse `/api/billing/info/` into the billing snapshot we report in `doctor`.
911///
912/// Only genuinely invalid JSON bytes fail; any valid JSON value (even a
913/// non-object such as `null` or `[]`) degrades to [`BillingInfo::default`].
914fn parse_billing_info(body: &[u8]) -> Result<BillingInfo> {
915    let data: Value = serde_json::from_slice(body)
916        .map_err(|err| Error::Api(format!("invalid billing JSON: {err}")))?;
917    Ok(from_billing_json(&data))
918}
919
920/// Map the raw billing JSON into the domain [`BillingInfo`].
921///
922/// Reads each field independently through `.get()`, defaulting to `None`/empty
923/// on a missing key or type mismatch, and never fails on a single field.
924/// `features` is the union of `accessible_features[].name` and
925/// `plan.usage_plan_features[].name`.
926fn from_billing_json(data: &Value) -> BillingInfo {
927    let plan = data.get("plan");
928    let mut features = BTreeSet::new();
929    collect_feature_names(data.get("accessible_features"), &mut features);
930    collect_feature_names(
931        plan.and_then(|plan| plan.get("usage_plan_features")),
932        &mut features,
933    );
934    BillingInfo {
935        total_credits_left: data.get("total_credits_left").and_then(json_i64),
936        monthly_limit: data.get("monthly_limit").and_then(json_i64),
937        monthly_usage: data.get("monthly_usage").and_then(json_i64),
938        credits: data.get("credits").and_then(json_i64),
939        period: json_string(data.get("period")),
940        period_end: json_string(data.get("period_end")),
941        renews_on: json_string(data.get("renews_on")),
942        is_active: data.get("is_active").and_then(Value::as_bool),
943        is_paused: data.get("is_paused").and_then(Value::as_bool),
944        is_past_due: data.get("is_past_due").and_then(Value::as_bool),
945        is_gifted: data.get("is_gifted").and_then(Value::as_bool),
946        subscription_platform: json_string(data.get("subscription_platform")),
947        plan_key: json_string(plan.and_then(|plan| plan.get("plan_key"))),
948        plan_name: json_string(plan.and_then(|plan| plan.get("name"))),
949        plan_level: plan.and_then(|plan| plan.get("level")).and_then(json_i64),
950        features,
951    }
952}
953
954/// Add the `name` of each `{ "name": ... }` element of a feature array to
955/// `out`, skipping non-arrays, non-object elements, and empty or missing names.
956fn collect_feature_names(array: Option<&Value>, out: &mut BTreeSet<String>) {
957    let Some(items) = array.and_then(Value::as_array) else {
958        return;
959    };
960    for name in items
961        .iter()
962        .filter_map(|item| item.get("name").and_then(Value::as_str))
963    {
964        if !name.is_empty() {
965            out.insert(name.to_owned());
966        }
967    }
968}
969
970/// Read an optional string field, cloning the value when present.
971fn json_string(value: Option<&Value>) -> Option<String> {
972    value.and_then(Value::as_str).map(str::to_owned)
973}
974
975/// Read a signed integer that Suno may encode as a JSON integer, an integral
976/// JSON float (`2450.0`), or a decimal string (`"2450"` or `"2450.0"`).
977///
978/// Non-integral values (`2450.5`), overflow, and junk yield `None`. The
979/// conversion is lossless and never saturates a value into range.
980fn json_i64(value: &Value) -> Option<i64> {
981    match value {
982        Value::Number(number) => number
983            .as_i64()
984            .or_else(|| number.as_f64().and_then(f64_to_i64)),
985        Value::String(text) => str_to_i64(text),
986        _ => None,
987    }
988}
989
990/// Convert a finite, integral `f64` to `i64`, rejecting fractional values and
991/// anything outside the exactly representable range.
992fn f64_to_i64(value: f64) -> Option<i64> {
993    // Beyond 2^53 an f64 cannot losslessly represent an integer: serde has
994    // already rounded (or saturated) such a value before we see it, so we
995    // refuse rather than return a wrong result. Below 2^53 the cast is exact.
996    if value.is_finite() && value.fract() == 0.0 && value.abs() < 9_007_199_254_740_992.0 {
997        Some(value as i64)
998    } else {
999        None
1000    }
1001}
1002
1003/// Parse a decimal string into `i64`, accepting an all-zero fractional part
1004/// (`"2450.0"`) but rejecting non-integral values, overflow, and junk.
1005fn str_to_i64(text: &str) -> Option<i64> {
1006    match text.split_once('.') {
1007        Some((integer, fraction)) => {
1008            let integral = fraction.is_empty() || fraction.bytes().all(|byte| byte == b'0');
1009            integral.then(|| integer.parse().ok()).flatten()
1010        }
1011        None => text.parse().ok(),
1012    }
1013}
1014
1015/// Build the JSON body for a `POST /api/feed/v3` page.
1016///
1017/// `filters.trashed` is the string `"False"` so the feed excludes trashed clips
1018/// exactly as the old v2 listing did; a `liked` walk adds `filters.liked =
1019/// "True"` (v3 ignores an `is_liked` key). The `cursor` is omitted on the first
1020/// page and set to the previous page's `next_cursor` thereafter.
1021fn feed_v3_body(liked: bool, cursor: Option<&str>) -> Vec<u8> {
1022    let mut filters = serde_json::Map::new();
1023    filters.insert("trashed".to_string(), Value::String("False".to_string()));
1024    if liked {
1025        filters.insert("liked".to_string(), Value::String("True".to_string()));
1026    }
1027    let mut body = serde_json::Map::new();
1028    body.insert("limit".to_string(), Value::from(FEED_PAGE_SIZE));
1029    body.insert("filters".to_string(), Value::Object(filters));
1030    if let Some(cursor) = cursor {
1031        body.insert("cursor".to_string(), Value::String(cursor.to_string()));
1032    }
1033    serde_json::to_vec(&Value::Object(body)).unwrap_or_default()
1034}
1035
1036/// One parsed v3 feed page.
1037///
1038/// `has_more` is [`None`] when the key is missing or not a bool, so the caller
1039/// can refuse to treat an unrecognised page as a fully drained feed. An empty
1040/// `next_cursor` string maps to [`None`] so it is never re-sent as a cursor.
1041/// `any_filtered` is `true` when the raw `clips[]` array held more entries than
1042/// survived the downloadable and non-empty-id filters, so the caller can disarm
1043/// deletion authority for a listing that may have hidden a tracked clip (#248).
1044struct FeedPage {
1045    clips: Vec<Clip>,
1046    has_more: Option<bool>,
1047    next_cursor: Option<String>,
1048    any_filtered: bool,
1049}
1050
1051/// Parse a v3 feed page into a [`FeedPage`].
1052fn parse_feed_v3(body: &[u8]) -> Result<FeedPage> {
1053    let data: Value = serde_json::from_slice(body)
1054        .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
1055    let Some(object) = data.as_object() else {
1056        return Ok(FeedPage {
1057            clips: Vec::new(),
1058            has_more: None,
1059            next_cursor: None,
1060            any_filtered: false,
1061        });
1062    };
1063    let raw = object.get("clips").and_then(Value::as_array);
1064    let raw_len = raw.map(|clips| clips.len()).unwrap_or(0);
1065    let clips: Vec<Clip> = raw
1066        .map(|raw| {
1067            raw.iter()
1068                .map(Clip::from_json)
1069                .filter(is_downloadable)
1070                .filter(|clip| !clip.id.is_empty())
1071                .collect()
1072        })
1073        .unwrap_or_default();
1074    // A member the feed still lists may have flipped off `complete` (or into an
1075    // excluded type/task) since it was downloaded, or arrived with a corrupted
1076    // (empty) id; dropping it silently here would make a tracked clip look
1077    // absent and delete its master. Surface any such loss so the caller can
1078    // refuse deletion authority for this listing, matching the playlist path's
1079    // empty-id and filter guards (#248, sibling of #148).
1080    let any_filtered = clips.len() < raw_len;
1081    let has_more = object.get("has_more").and_then(Value::as_bool);
1082    let next_cursor = object
1083        .get("next_cursor")
1084        .and_then(Value::as_str)
1085        .filter(|cursor| !cursor.is_empty())
1086        .map(str::to_string);
1087    Ok(FeedPage {
1088        clips,
1089        has_more,
1090        next_cursor,
1091        any_filtered,
1092    })
1093}
1094
1095/// Parse a `/api/playlist/me` page into playlists, dropping entries with no id.
1096fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
1097    let data: Value = serde_json::from_slice(body)
1098        .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
1099    Ok(data
1100        .get("playlists")
1101        .and_then(Value::as_array)
1102        .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
1103        .unwrap_or_default())
1104}
1105
1106/// Map one raw `/api/playlist/me` entry, or `None` when it carries no id.
1107///
1108/// `num_total_results` is the playlist's member count; a missing name defaults
1109/// to `Untitled` (matching the clip mapping) so the file name is never empty.
1110fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
1111    let id = raw
1112        .get("id")
1113        .and_then(Value::as_str)
1114        .filter(|id| !id.is_empty())?
1115        .to_string();
1116    let name = match raw.get("name") {
1117        Some(Value::String(name)) if !name.is_empty() => name.clone(),
1118        _ => "Untitled".to_string(),
1119    };
1120    let num_clips = raw
1121        .get("num_total_results")
1122        .and_then(Value::as_u64)
1123        .unwrap_or(0);
1124    Some(Playlist {
1125        id,
1126        name,
1127        num_clips,
1128    })
1129}
1130
1131/// Parse a `/api/playlist/{id}/` body into its ordered member clips plus a
1132/// completeness flag.
1133///
1134/// Each `playlist_clips[]` entry wraps the clip under `clip`; the wrapper is
1135/// unwrapped (falling back to the entry itself), order is preserved exactly, and
1136/// only clips with a non-empty id survive. No downloadability filter is applied:
1137/// a playlist may hold any clip, and members absent from the local library are
1138/// reconciled as comment lines by the caller, not dropped here. The scoped-sync
1139/// path applies [`is_downloadable`](crate::is_downloadable) itself when it fetches
1140/// members as download candidates.
1141///
1142/// The completeness flag is `true` only when the response's `num_total_results`
1143/// is present, equals the raw `playlist_clips[]` count, and no member was
1144/// dropped by the empty-id filter, i.e. the whole member set arrived intact on
1145/// this single page. It gates a Mirror playlist area's deletion authority (D5):
1146/// a short or paginated page, or one carrying a member with a missing/empty
1147/// clip id, cannot be authoritative for deletion, so it returns `false`.
1148fn parse_playlist_clips(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
1149    let data: Value = serde_json::from_slice(body)
1150        .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
1151    let raw = data.get("playlist_clips").and_then(Value::as_array);
1152    let raw_len = raw.map(|a| a.len()).unwrap_or(0);
1153    let clips: Vec<Clip> = raw
1154        .map(|raw| {
1155            raw.iter()
1156                .map(|entry| Clip::from_json(unwrap_clip(entry)))
1157                .filter(|clip| !clip.id.is_empty())
1158                .collect()
1159        })
1160        .unwrap_or_default();
1161    // Completeness requires the reported total to be present and to match the
1162    // raw entry count (before the empty-id filter) AND no member to have been
1163    // dropped by that filter (`clips.len() == raw_len`). A missing or malformed
1164    // total, a short page, or a single dropped member (empty/missing clip id)
1165    // all fail safe toward "not authoritative", so a Mirror area can never
1166    // delete from a page whose whole member set was not seen intact.
1167    let complete = data
1168        .get("num_total_results")
1169        .and_then(Value::as_u64)
1170        .is_some_and(|total| raw_len as u64 == total && clips.len() == raw_len);
1171    Ok((clips, complete))
1172}
1173
1174#[cfg(test)]
1175mod tests {
1176    use super::*;
1177    use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
1178    use std::time::Duration;
1179
1180    fn feed_body() -> String {
1181        serde_json::json!({
1182            "has_more": false,
1183            "clips": [
1184                {
1185                    "id": "a", "title": "Song A", "status": "complete",
1186                    "audio_url": "https://cdn1.suno.ai/a.mp3",
1187                    "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
1188                },
1189                {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
1190                {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
1191                {
1192                    "id": "d", "title": "Context", "status": "complete",
1193                    "metadata": {"type": "rendered_context_window"}
1194                }
1195            ]
1196        })
1197        .to_string()
1198    }
1199
1200    #[test]
1201    fn parse_feed_v3_filters_and_reads_pagination() {
1202        let page = parse_feed_v3(feed_body().as_bytes()).unwrap();
1203        assert_eq!(page.has_more, Some(false));
1204        assert_eq!(page.next_cursor, None);
1205        assert_eq!(page.clips.len(), 1);
1206        assert_eq!(page.clips[0].id, "a");
1207        assert_eq!(page.clips[0].tags, "rock");
1208        assert!((page.clips[0].duration - 120.5).abs() < f64::EPSILON);
1209    }
1210
1211    #[test]
1212    fn parse_feed_v3_flags_a_dropped_clip_as_filtered() {
1213        // feed_body() lists four clips but only one survives is_downloadable, so
1214        // a tracked clip that flipped off `complete` would be hidden here; the
1215        // flag warns the caller to disarm deletion (#248).
1216        let page = parse_feed_v3(feed_body().as_bytes()).unwrap();
1217        assert_eq!(page.clips.len(), 1);
1218        assert!(page.any_filtered);
1219
1220        // A page whose every clip is downloadable loses nothing.
1221        let clean = serde_json::json!({
1222            "has_more": false,
1223            "clips": [{"id": "a", "status": "complete", "metadata": {"type": "gen"}}]
1224        })
1225        .to_string();
1226        let page = parse_feed_v3(clean.as_bytes()).unwrap();
1227        assert_eq!(page.clips.len(), 1);
1228        assert!(!page.any_filtered);
1229
1230        // A complete clip with a corrupted (empty) id is dropped and counted as
1231        // loss, so a tracked clip cannot be hidden behind an id-less entry
1232        // (parity with the playlist path).
1233        let empty_id = serde_json::json!({
1234            "has_more": false,
1235            "clips": [
1236                {"id": "kept", "status": "complete", "metadata": {"type": "gen"}},
1237                {"id": "", "status": "complete", "metadata": {"type": "gen"}}
1238            ]
1239        })
1240        .to_string();
1241        let page = parse_feed_v3(empty_id.as_bytes()).unwrap();
1242        assert_eq!(page.clips.len(), 1);
1243        assert_eq!(page.clips[0].id, "kept");
1244        assert!(page.any_filtered);
1245    }
1246
1247    /// One real anonymised `POST /api/feed/v3` page (issue #219): a single
1248    /// downloadable clip carrying `media_urls`, `user_id`, `batch_index`, cdn2
1249    /// artwork, and a pagination envelope with `has_more`/`next_cursor`.
1250    const FEED_V3_PAGE: &str = r#"{
1251      "clips": [
1252        {
1253          "status": "complete",
1254          "title": "Track 31",
1255          "id": "00000000-0000-4000-8000-000000000076",
1256          "entity_type": "song_schema",
1257          "video_url": "",
1258          "audio_url": "https://cdn1.suno.ai/00000000-0000-4000-8000-000000000076.mp3",
1259          "media_urls": [
1260            {
1261              "url": "https://media.cloudfront.net/1/clip/00000000-0000-4000-8000-000000000076.m4a",
1262              "content_type": "m4a-opus",
1263              "delivery": "progressive",
1264              "encoding": "1.0.0"
1265            },
1266            {
1267              "url": "https://cdn1.suno.ai/00000000-0000-4000-8000-000000000076.mp3",
1268              "content_type": "mp3",
1269              "delivery": "progressive"
1270            }
1271          ],
1272          "image_url": "https://cdn2.suno.ai/image_00000000-0000-4000-8000-000000000076.jpeg",
1273          "image_large_url": "https://cdn2.suno.ai/image_large_00000000-0000-4000-8000-000000000076.jpeg",
1274          "major_model_version": "v4.5",
1275          "model_name": "chirp-ahi",
1276          "metadata": {
1277            "tags": "",
1278            "type": "gen",
1279            "duration": 272.0,
1280            "task": "gen_stem",
1281            "has_stem": false
1282          },
1283          "is_liked": false,
1284          "user_id": "00000000-0000-4000-8000-000000000019",
1285          "display_name": "Example Artist 4",
1286          "handle": "example-artist-1",
1287          "is_trashed": false,
1288          "is_hidden": false,
1289          "created_at": "2026-07-03T13:15:10.635Z",
1290          "is_public": false,
1291          "explicit": false,
1292          "batch_index": 23,
1293          "clip_roots": {
1294            "clips": [
1295              {
1296                "id": "00000000-0000-4000-8000-000000000028",
1297                "title": "Track 7",
1298                "image_url": "https://cdn2.suno.ai/image_00000000-0000-4000-8000-000000000028.jpeg",
1299                "is_public": false,
1300                "user_display_name": "Example Artist 4",
1301                "user_handle": "example-artist-1",
1302                "user_avatar_image_url": "https://cdn1.suno.ai/avatar.jpg"
1303              }
1304            ],
1305            "clip_attribution_type": "remix"
1306          }
1307        }
1308      ],
1309      "has_more": true,
1310      "next_cursor": "cursor-token"
1311    }"#;
1312
1313    #[test]
1314    fn parse_feed_v3_page_maps_real_body_and_pagination() {
1315        let FeedPage {
1316            clips,
1317            has_more,
1318            next_cursor,
1319            ..
1320        } = parse_feed_v3(FEED_V3_PAGE.as_bytes()).unwrap();
1321        assert_eq!(has_more, Some(true));
1322        assert_eq!(next_cursor.as_deref(), Some("cursor-token"));
1323        // The single gen_stem clip is complete and passes is_downloadable.
1324        assert_eq!(clips.len(), 1);
1325        let clip = &clips[0];
1326        assert_eq!(clip.id, "00000000-0000-4000-8000-000000000076");
1327        assert_eq!(clip.title, "Track 31");
1328        assert_eq!(clip.model_name, "chirp-ahi");
1329        assert_eq!(clip.major_model_version, "v4.5");
1330        assert_eq!(clip.user_id, "00000000-0000-4000-8000-000000000019");
1331        assert_eq!(clip.batch_index, Some(23));
1332        // The cdn2 artwork host is rewritten to cdn1.
1333        assert_eq!(
1334            clip.image_url,
1335            "https://cdn1.suno.ai/image_00000000-0000-4000-8000-000000000076.jpeg"
1336        );
1337        assert!(clip.image_large_url.starts_with("https://cdn1.suno.ai/"));
1338        // media_urls carries both assets; mp3_url prefers the listed mp3.
1339        assert_eq!(clip.media_urls.len(), 2);
1340        assert_eq!(clip.media_urls[0].content_type, "m4a-opus");
1341        assert_eq!(
1342            clip.mp3_url(),
1343            "https://cdn1.suno.ai/00000000-0000-4000-8000-000000000076.mp3"
1344        );
1345        // A feed clip carries the same nested clip_roots shape as /api/clip/{id}.
1346        assert_eq!(clip.clip_attribution_type, "remix");
1347        assert_eq!(clip.clip_roots.len(), 1);
1348        assert_eq!(
1349            clip.clip_roots[0].id,
1350            "00000000-0000-4000-8000-000000000028"
1351        );
1352        assert_eq!(clip.clip_roots[0].handle, "example-artist-1");
1353    }
1354
1355    #[test]
1356    fn parse_feed_v3_page_survives_stripped_optional_fields() {
1357        // A clip with explicit/ownership/clip_roots/media_urls all stripped still
1358        // parses with sane defaults (the 490/458-of-500 optionality reality).
1359        let stripped = serde_json::json!({
1360            "clips": [{
1361                "id": "bare", "title": "Bare", "status": "complete",
1362                "metadata": {"type": "gen"}
1363            }],
1364            "has_more": false
1365        })
1366        .to_string();
1367        let FeedPage {
1368            clips,
1369            has_more,
1370            next_cursor,
1371            ..
1372        } = parse_feed_v3(stripped.as_bytes()).unwrap();
1373        assert_eq!(has_more, Some(false));
1374        assert_eq!(next_cursor, None);
1375        assert_eq!(clips.len(), 1);
1376        assert!(clips[0].media_urls.is_empty());
1377        assert_eq!(clips[0].user_id, "");
1378        assert_eq!(clips[0].batch_index, None);
1379    }
1380
1381    #[test]
1382    fn feed_v3_body_carries_filters_and_optional_cursor() {
1383        let first: Value = serde_json::from_slice(&feed_v3_body(false, None)).unwrap();
1384        assert_eq!(first["filters"]["trashed"], "False");
1385        assert!(first.get("cursor").is_none());
1386        assert!(first["filters"].get("liked").is_none());
1387
1388        let liked: Value = serde_json::from_slice(&feed_v3_body(true, Some("cur42"))).unwrap();
1389        assert_eq!(liked["filters"]["liked"], "True");
1390        assert_eq!(liked["cursor"], "cur42");
1391    }
1392
1393    #[test]
1394    fn audiopipe_url_is_rewritten_to_cdn() {
1395        let raw =
1396            serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
1397        assert_eq!(
1398            Clip::from_json(&raw).audio_url,
1399            "https://cdn1.suno.ai/x.mp3"
1400        );
1401    }
1402
1403    #[test]
1404    fn list_clips_authenticates_then_reads_the_feed() {
1405        let client_body = serde_json::json!({
1406            "response": {
1407                "last_active_session_id": "s",
1408                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
1409            }
1410        })
1411        .to_string();
1412        let http = MockHttp::new(vec![
1413            Rule::new(
1414                "/v1/client/sessions/",
1415                200,
1416                r#"{"jwt": "a.b.c"}"#.to_string(),
1417            ),
1418            Rule::new("/v1/client", 200, client_body),
1419            Rule::new("/api/feed/v3", 200, feed_body()),
1420        ]);
1421
1422        let auth = ClerkAuth::new("eyJtoken");
1423        pollster::block_on(auth.authenticate(&http)).unwrap();
1424        let client = SunoClient::new(auth, RecordingClock::new());
1425        let (clips, complete, _) =
1426            pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1427        assert_eq!(clips.len(), 1);
1428        assert_eq!(clips[0].id, "a");
1429        assert!(complete);
1430    }
1431
1432    #[test]
1433    fn api_request_uses_clock_now_unix_for_jwt_expiry() {
1434        use crate::consts::JWT_REFRESH_BUFFER;
1435        use base64::Engine;
1436        let exp = 1_000_000i64;
1437        let payload =
1438            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#));
1439        let jwt_str = format!("hdr.{}.sig", payload);
1440        let token_body = format!(r#"{{"jwt": "{jwt_str}"}}"#);
1441        let client_body = serde_json::json!({
1442            "response": {
1443                "last_active_session_id": "s",
1444                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
1445            }
1446        })
1447        .to_string();
1448
1449        let make_http = || {
1450            ScriptedHttp::new()
1451                .route("/v1/client/sessions/", Reply::json(&token_body))
1452                .route("/v1/client", Reply::json(&client_body))
1453                .route("/api/feed/v3", Reply::json(&feed_body()))
1454        };
1455
1456        // At the refresh boundary: ensure_jwt triggers a second refresh_jwt call.
1457        let http = make_http();
1458        let auth = ClerkAuth::new("eyJtoken");
1459        pollster::block_on(auth.authenticate(&http)).unwrap();
1460        let client = SunoClient::new(auth, RecordingClock::at(exp - JWT_REFRESH_BUFFER));
1461        let (clips, _, _) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1462        assert_eq!(clips.len(), 1);
1463        // authenticate + api_request refresh = 2 token calls.
1464        assert_eq!(http.count("/v1/client/sessions/"), 2);
1465
1466        // Just before the boundary: no additional refresh.
1467        let http2 = make_http();
1468        let auth2 = ClerkAuth::new("eyJtoken");
1469        pollster::block_on(auth2.authenticate(&http2)).unwrap();
1470        let client2 = SunoClient::new(auth2, RecordingClock::at(exp - JWT_REFRESH_BUFFER - 1));
1471        let (clips2, _, _) = pollster::block_on(client2.list_clips(&http2, false, None)).unwrap();
1472        assert_eq!(clips2.len(), 1);
1473        // Only authenticate's token call; no extra refresh.
1474        assert_eq!(http2.count("/v1/client/sessions/"), 1);
1475    }
1476
1477    #[test]
1478    fn list_clips_reports_incomplete_when_paging_is_capped() {
1479        let mut rules = auth_rules();
1480        rules.push(Rule::new(
1481            "/api/feed/v3",
1482            200,
1483            serde_json::json!({
1484                "has_more": true,
1485                "next_cursor": "cur1",
1486                "clips": [{
1487                    "id": "a", "title": "Song A", "status": "complete",
1488                    "audio_url": "https://cdn1.suno.ai/a.mp3",
1489                    "metadata": {"type": "gen"}
1490                }]
1491            })
1492            .to_string(),
1493        ));
1494        let http = MockHttp::new(rules);
1495        let client = authed_client(&http);
1496
1497        let (_clips, complete, _) =
1498            pollster::block_on(client.list_clips(&http, false, None)).unwrap();
1499        assert!(!complete);
1500    }
1501
1502    fn auth_rules() -> Vec<Rule> {
1503        let client_body = serde_json::json!({
1504            "response": {
1505                "last_active_session_id": "s",
1506                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
1507            }
1508        })
1509        .to_string();
1510        vec![
1511            Rule::new(
1512                "/v1/client/sessions/",
1513                200,
1514                r#"{"jwt": "a.b.c"}"#.to_string(),
1515            ),
1516            Rule::new("/v1/client", 200, client_body),
1517        ]
1518    }
1519
1520    fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
1521        let auth = ClerkAuth::new("eyJtoken");
1522        pollster::block_on(auth.authenticate(http)).unwrap();
1523        SunoClient::new(auth, RecordingClock::new())
1524    }
1525
1526    #[test]
1527    fn get_billing_info_reads_remaining_credits() {
1528        let mut rules = auth_rules();
1529        rules.push(Rule::new(
1530            BILLING_INFO_PATH,
1531            200,
1532            r#"{"total_credits_left":500,"monthly_limit":1000,"monthly_usage":500}"#.to_string(),
1533        ));
1534        let http = MockHttp::new(rules);
1535        let client = authed_client(&http);
1536
1537        let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
1538        assert_eq!(billing.total_credits_left, Some(500));
1539        assert_eq!(billing.monthly_limit, Some(1000));
1540        assert_eq!(billing.monthly_usage, Some(500));
1541    }
1542
1543    #[test]
1544    fn get_billing_info_tolerates_missing_balance() {
1545        let mut rules = auth_rules();
1546        rules.push(Rule::new(
1547            BILLING_INFO_PATH,
1548            200,
1549            r#"{"monthly_usage":12}"#.to_string(),
1550        ));
1551        let http = MockHttp::new(rules);
1552        let client = authed_client(&http);
1553
1554        let billing = pollster::block_on(client.get_billing_info(&http)).unwrap();
1555        assert_eq!(billing.total_credits_left, None);
1556        assert_eq!(billing.monthly_usage, Some(12));
1557    }
1558
1559    /// The anonymised full 43-field `GET /api/billing/info/` body from issue
1560    /// #223, used as a real-shape parse fixture.
1561    const BILLING_FULL: &str = r#"{
1562  "subscription_platform": "stripe",
1563  "is_active": true,
1564  "is_past_due": false,
1565  "credits": 0,
1566  "subscription_type": true,
1567  "subscription_anchor": "REDACTED",
1568  "subscription_id": "REDACTED",
1569  "renews_on": "REDACTED",
1570  "period": "month",
1571  "monthly_usage": 50,
1572  "monthly_limit": 2500,
1573  "credit_packs": [
1574    {
1575      "id": "00000000-0000-4000-8000-000000000001",
1576      "amount": 500,
1577      "price_usd": 4
1578    },
1579    {
1580      "id": "00000000-0000-4000-8000-000000000002",
1581      "amount": 1000,
1582      "price_usd": 8
1583    }
1584  ],
1585  "plan": {
1586    "id": "00000000-0000-4000-8000-000000000005",
1587    "level": 10,
1588    "plan_key": "pro",
1589    "name": "Pro Plan",
1590    "features": "Access to our newest model, v4\n2,500 credits (up to 500 songs), refreshes monthly\nCommercial use rights for songs made while subscribed\nCreate up to 10 songs at once\nEarly access to new features\nPriority creation queue\nAbility to purchase add-on credits",
1591    "monthly_price_usd": 10.0,
1592    "annual_price_usd": 96.0,
1593    "usage_plan_features": [
1594      {
1595        "name": "v4"
1596      },
1597      {
1598        "name": "cover"
1599      },
1600      {
1601        "name": "edit_mode"
1602      },
1603      {
1604        "name": "persona"
1605      },
1606      {
1607        "name": "can_buy_credit_top_ups"
1608      },
1609      {
1610        "name": "commercial_rights"
1611      },
1612      {
1613        "name": "get_stems"
1614      },
1615      {
1616        "name": "generate_song_image"
1617      },
1618      {
1619        "name": "auk"
1620      },
1621      {
1622        "name": "negative_tags"
1623      },
1624      {
1625        "name": "remaster"
1626      },
1627      {
1628        "name": "generate_song_video"
1629      },
1630      {
1631        "name": "long_uploads"
1632      },
1633      {
1634        "name": "convert_audio"
1635      },
1636      {
1637        "name": "create_control_sliders"
1638      },
1639      {
1640        "name": "playlist_condition"
1641      },
1642      {
1643        "name": "tag_upsample"
1644      },
1645      {
1646        "name": "custom_models"
1647      }
1648    ]
1649  },
1650  "models": [
1651    {
1652      "can_use": true,
1653      "max_lengths": {
1654        "title": 100,
1655        "prompt": 5000,
1656        "tags": 1000,
1657        "negative_tags": 1000,
1658        "gpt_description_prompt": 3000
1659      },
1660      "name": "Example Artist 5",
1661      "external_key": "chirp-fenix",
1662      "major_version": 5,
1663      "description": "[description redacted]",
1664      "is_default_free_model": false,
1665      "is_default_model": true,
1666      "badges": [
1667        "pro"
1668      ],
1669      "model_badges": [
1670        {
1671          "display_name": "Example Artist 1",
1672          "light": {
1673            "text_color": "000000",
1674            "background_color": "00000000",
1675            "border_color": "000000"
1676          },
1677          "dark": {
1678            "text_color": "FFFFFF",
1679            "background_color": "00000000",
1680            "border_color": "FFFFFF"
1681          }
1682        }
1683      ],
1684      "style": {
1685        "light": {
1686          "text_color": "FD429C"
1687        },
1688        "dark": {
1689          "text_color": "FD429C"
1690        }
1691      },
1692      "capabilities": [
1693        "all"
1694      ],
1695      "features": [
1696        "create_control_sliders",
1697        "tag_upsample",
1698        "mumble_mode",
1699        "vox_and_voices",
1700        "reuse_styles_lyrics"
1701      ],
1702      "allowed_condition_combinations": [
1703        [
1704          "extend"
1705        ],
1706        [
1707          "cover"
1708        ],
1709        [
1710          "infill"
1711        ],
1712        [
1713          "persona"
1714        ],
1715        [
1716          "persona",
1717          "extend"
1718        ],
1719        [
1720          "persona",
1721          "cover"
1722        ],
1723        [
1724          "playlist"
1725        ],
1726        [
1727          "underpaint"
1728        ],
1729        [
1730          "overpaint"
1731        ],
1732        [
1733          "vox"
1734        ],
1735        [
1736          "vox",
1737          "extend"
1738        ],
1739        [
1740          "vox",
1741          "cover"
1742        ],
1743        [
1744          "vox",
1745          "playlist"
1746        ],
1747        [
1748          "persona",
1749          "infill"
1750        ],
1751        [
1752          "cover",
1753          "infill"
1754        ]
1755      ],
1756      "id": "00000000-0000-4000-8000-000000000006"
1757    }
1758  ],
1759  "plan_price": 10.0,
1760  "plan_currency": "AUD",
1761  "plan_currency_price": 15.0,
1762  "payment_method_type": "card",
1763  "can_upgrade_immediately": true,
1764  "plans": [
1765    {
1766      "id": "00000000-0000-4000-8000-000000000015",
1767      "level": 0,
1768      "plan_key": "free",
1769      "name": "Free Plan",
1770      "features": "50 credits renew daily (10 songs)\nCreate up to 4 songs at once\nNo commercial use\nNo credit top ups\nShared generation queue",
1771      "monthly_price_usd": 0.0,
1772      "annual_price_usd": 0.0,
1773      "usage_plan_features": [
1774        {
1775          "name": "tag_upsample"
1776        }
1777      ],
1778      "prices": []
1779    }
1780  ],
1781  "accessible_features": [
1782    {
1783      "name": "v4"
1784    },
1785    {
1786      "name": "cover"
1787    },
1788    {
1789      "name": "edit_mode"
1790    },
1791    {
1792      "name": "persona"
1793    },
1794    {
1795      "name": "can_buy_credit_top_ups"
1796    },
1797    {
1798      "name": "commercial_rights"
1799    },
1800    {
1801      "name": "get_stems"
1802    },
1803    {
1804      "name": "generate_song_image"
1805    },
1806    {
1807      "name": "auk"
1808    },
1809    {
1810      "name": "negative_tags"
1811    },
1812    {
1813      "name": "remaster"
1814    },
1815    {
1816      "name": "generate_song_video"
1817    },
1818    {
1819      "name": "long_uploads"
1820    },
1821    {
1822      "name": "convert_audio"
1823    },
1824    {
1825      "name": "create_control_sliders"
1826    },
1827    {
1828      "name": "playlist_condition"
1829    },
1830    {
1831      "name": "tag_upsample"
1832    },
1833    {
1834      "name": "custom_models"
1835    }
1836  ],
1837  "revcat_subscriptions_offering_id": "REDACTED",
1838  "total_credits_left": 2450,
1839  "free_persona_clips_remaining": 0,
1840  "free_cover_clips_remaining": 0,
1841  "free_remasters_remaining": 0,
1842  "free_mobile_remasters_remaining": 0,
1843  "free_mobile_v4_gens_remaining": 0,
1844  "free_web_v4_gens_remaining": 0,
1845  "free_vox_gens_remaining": 0,
1846  "has_been_subscriber_before": true,
1847  "has_valid_school_email": false,
1848  "has_been_student_subscriber_before": false,
1849  "day0_boost": -1,
1850  "promotions": [],
1851  "audio_upload_limits": {
1852    "min": 6,
1853    "max": 1800
1854  },
1855  "voice_upload_limits": {
1856    "min": 10,
1857    "max": 900
1858  },
1859  "voice_record_limits": {
1860    "min": 10,
1861    "max": 240
1862  },
1863  "period_end": "REDACTED",
1864  "remaster_model_types": [
1865    {
1866      "name": "Example Artist 5",
1867      "external_key": "chirp-flounder",
1868      "is_default_model": true,
1869      "can_use": false
1870    },
1871    {
1872      "name": "Example Artist 2",
1873      "external_key": "chirp-carp",
1874      "is_default_model": false,
1875      "can_use": false
1876    },
1877    {
1878      "name": "v4.5+",
1879      "external_key": "chirp-bass",
1880      "is_default_model": false,
1881      "can_use": false
1882    }
1883  ],
1884  "is_pause_scheduled": false,
1885  "is_paused": false,
1886  "is_gifted": false
1887}"#;
1888
1889    #[test]
1890    fn parse_billing_info_reads_full_real_body() {
1891        let billing = parse_billing_info(BILLING_FULL.as_bytes()).unwrap();
1892        assert_eq!(billing.total_credits_left, Some(2450));
1893        assert_eq!(billing.monthly_limit, Some(2500));
1894        assert_eq!(billing.monthly_usage, Some(50));
1895        assert_eq!(billing.credits, Some(0));
1896        assert_eq!(billing.period.as_deref(), Some("month"));
1897        assert_eq!(billing.is_active, Some(true));
1898        assert_eq!(billing.is_paused, Some(false));
1899        assert_eq!(billing.is_past_due, Some(false));
1900        assert_eq!(billing.is_gifted, Some(false));
1901        assert_eq!(billing.subscription_platform.as_deref(), Some("stripe"));
1902        assert_eq!(billing.plan_key.as_deref(), Some("pro"));
1903        assert_eq!(billing.plan_name.as_deref(), Some("Pro Plan"));
1904        assert_eq!(billing.plan_level, Some(10));
1905        assert!(billing.can_get_stems());
1906        assert!(billing.can_convert_audio());
1907        assert!(billing.has_feature("custom_models"));
1908    }
1909
1910    #[test]
1911    fn json_i64_reads_string_encoded_integer() {
1912        let billing = parse_billing_info(br#"{"total_credits_left":"2450"}"#).unwrap();
1913        assert_eq!(billing.total_credits_left, Some(2450));
1914    }
1915
1916    #[test]
1917    fn json_i64_reads_integral_float() {
1918        let billing = parse_billing_info(br#"{"total_credits_left":2450.0}"#).unwrap();
1919        assert_eq!(billing.total_credits_left, Some(2450));
1920    }
1921
1922    #[test]
1923    fn json_i64_reads_negative_sentinel() {
1924        let billing = parse_billing_info(br#"{"total_credits_left":-1}"#).unwrap();
1925        assert_eq!(billing.total_credits_left, Some(-1));
1926    }
1927
1928    #[test]
1929    fn json_i64_rejects_non_integral_float_but_object_still_parses() {
1930        let billing =
1931            parse_billing_info(br#"{"total_credits_left":2450.5,"period":"month"}"#).unwrap();
1932        assert_eq!(billing.total_credits_left, None);
1933        assert_eq!(billing.period.as_deref(), Some("month"));
1934    }
1935
1936    #[test]
1937    fn str_to_i64_handles_encodings_and_junk() {
1938        assert_eq!(str_to_i64("2450"), Some(2450));
1939        assert_eq!(str_to_i64("2450.0"), Some(2450));
1940        assert_eq!(str_to_i64("-1"), Some(-1));
1941        assert_eq!(str_to_i64("2450.5"), None);
1942        assert_eq!(str_to_i64(".5"), None);
1943        assert_eq!(str_to_i64("nope"), None);
1944        assert_eq!(str_to_i64("99999999999999999999999"), None);
1945    }
1946
1947    #[test]
1948    fn json_i64_rejects_overflow() {
1949        let billing =
1950            parse_billing_info(br#"{"total_credits_left":99999999999999999999999}"#).unwrap();
1951        assert_eq!(billing.total_credits_left, None);
1952    }
1953
1954    #[test]
1955    fn json_i64_covers_i64_and_float_boundaries() {
1956        // Integers arrive through the lossless i64 path, so the full i64 range works.
1957        assert_eq!(json_i64(&serde_json::json!(i64::MAX)), Some(i64::MAX));
1958        assert_eq!(json_i64(&serde_json::json!(i64::MIN)), Some(i64::MIN));
1959        // A JSON integer of 2^63 exceeds i64::MAX and must not saturate.
1960        assert_eq!(
1961            json_i64(&serde_json::json!(9_223_372_036_854_775_808_u64)),
1962            None
1963        );
1964        // Floats are trusted only below 2^53, so both i64 extremes are rejected.
1965        assert_eq!(f64_to_i64(i64::MAX as f64), None);
1966        assert_eq!(f64_to_i64(i64::MIN as f64), None);
1967        assert_eq!(f64_to_i64(2450.5), None);
1968        assert_eq!(f64_to_i64(f64::NAN), None);
1969        assert_eq!(f64_to_i64(f64::INFINITY), None);
1970    }
1971
1972    #[test]
1973    fn f64_to_i64_rejects_values_below_i64_min() {
1974        // A float below i64::MIN must not silently saturate to i64::MIN.
1975        let below_min: f64 = "-9223372036854775809".parse().unwrap();
1976        assert_eq!(f64_to_i64(below_min), None);
1977        // The matching string is rejected by the lossless i64 parse.
1978        assert_eq!(str_to_i64("-9223372036854775809"), None);
1979        assert_eq!(json_i64(&serde_json::json!("-9223372036854775809")), None);
1980    }
1981
1982    #[test]
1983    fn f64_to_i64_trusts_only_the_safe_integer_range() {
1984        // 2^53 - 1 is the largest integer an f64 represents exactly.
1985        assert_eq!(
1986            f64_to_i64(9_007_199_254_740_991.0),
1987            Some(9_007_199_254_740_991)
1988        );
1989        // 9007199254740993 (2^53 + 1) is not representable, so serde rounds it to
1990        // 2^53 before we see it; the rounded value must be refused, not returned.
1991        let rounded: f64 = "9007199254740993".parse().unwrap();
1992        assert_eq!(rounded, 9_007_199_254_740_992.0);
1993        assert_eq!(f64_to_i64(rounded), None);
1994    }
1995
1996    #[test]
1997    fn parse_billing_info_defaults_missing_fields() {
1998        let billing = parse_billing_info(br#"{"monthly_usage":12}"#).unwrap();
1999        assert_eq!(billing.total_credits_left, None);
2000        assert_eq!(billing.monthly_usage, Some(12));
2001        assert_eq!(billing.plan_key, None);
2002        assert!(billing.features.is_empty());
2003        assert!(!billing.can_get_stems());
2004    }
2005
2006    #[test]
2007    fn from_billing_json_ignores_surprising_types() {
2008        // `subscription_type` is a bool despite its name; a numeric field carrying
2009        // the wrong type must fall back to None rather than panic.
2010        let value = serde_json::json!({
2011            "subscription_type": true,
2012            "total_credits_left": {"unexpected": "object"},
2013            "is_active": "yes",
2014        });
2015        let billing = from_billing_json(&value);
2016        assert_eq!(billing.total_credits_left, None);
2017        assert_eq!(billing.is_active, None);
2018    }
2019
2020    #[test]
2021    fn parse_billing_info_treats_non_object_json_as_default() {
2022        for body in [
2023            b"null".as_slice(),
2024            b"[]".as_slice(),
2025            br#""hello""#.as_slice(),
2026        ] {
2027            assert_eq!(parse_billing_info(body).unwrap(), BillingInfo::default());
2028        }
2029    }
2030
2031    #[test]
2032    fn parse_billing_info_rejects_non_json_bytes() {
2033        let err = parse_billing_info(b"nope").unwrap_err();
2034        assert!(err.to_string().contains("invalid billing JSON"));
2035    }
2036
2037    #[test]
2038    fn from_billing_json_unions_feature_sources() {
2039        let accessible_only = serde_json::json!({
2040            "accessible_features": [{"name": "get_stems"}],
2041        });
2042        assert!(from_billing_json(&accessible_only).can_get_stems());
2043
2044        let plan_only = serde_json::json!({
2045            "plan": {"usage_plan_features": [{"name": "convert_audio"}]},
2046        });
2047        assert!(from_billing_json(&plan_only).can_convert_audio());
2048
2049        let both = serde_json::json!({
2050            "accessible_features": [{"name": "get_stems"}, {"name": ""}, {"other": "x"}],
2051            "plan": {"usage_plan_features": [{"name": "convert_audio"}]},
2052        });
2053        let billing = from_billing_json(&both);
2054        assert!(billing.can_get_stems());
2055        assert!(billing.can_convert_audio());
2056        // Empty and malformed feature entries are ignored.
2057        assert_eq!(billing.features.len(), 2);
2058    }
2059
2060    #[test]
2061    fn aligned_lyrics_reads_words_and_lines() {
2062        let mut rules = auth_rules();
2063        let body = serde_json::json!({
2064            "aligned_words": [
2065                {"word": "hi", "success": true, "start_s": 0.5, "end_s": 0.9, "p_align": 0.99}
2066            ],
2067            "aligned_lyrics": [
2068                {"text": "hi", "start_s": 0.5, "end_s": 0.9, "section": "Verse 1",
2069                 "words": [{"text": "hi", "start_s": 0.5, "end_s": 0.9}]}
2070            ],
2071            "hoot_cer": 0.2, "is_streamed": false
2072        })
2073        .to_string();
2074        rules.push(Rule::new("/aligned_lyrics/v2/", 200, body));
2075        let http = MockHttp::new(rules);
2076        let client = authed_client(&http);
2077
2078        let aligned = pollster::block_on(client.aligned_lyrics(&http, "clip-1")).unwrap();
2079        assert_eq!(aligned.words.len(), 1);
2080        assert_eq!(aligned.lines.len(), 1);
2081        assert_eq!(aligned.lines[0].section, "Verse 1");
2082        assert!(!aligned.is_empty());
2083    }
2084
2085    #[test]
2086    fn aligned_lyrics_empty_arrays_map_to_empty() {
2087        let mut rules = auth_rules();
2088        rules.push(Rule::new(
2089            "/aligned_lyrics/v2/",
2090            200,
2091            r#"{"aligned_words":[],"aligned_lyrics":[],"hoot_cer":1.0}"#.to_string(),
2092        ));
2093        let http = MockHttp::new(rules);
2094        let client = authed_client(&http);
2095
2096        let aligned = pollster::block_on(client.aligned_lyrics(&http, "instr")).unwrap();
2097        assert!(aligned.is_empty());
2098    }
2099
2100    #[test]
2101    fn aligned_lyrics_maps_404_to_empty() {
2102        let mut rules = auth_rules();
2103        rules.push(Rule::new(
2104            "/aligned_lyrics/v2/",
2105            404,
2106            "not found".to_string(),
2107        ));
2108        let http = MockHttp::new(rules);
2109        let client = authed_client(&http);
2110
2111        let aligned = pollster::block_on(client.aligned_lyrics(&http, "missing")).unwrap();
2112        assert!(aligned.is_empty());
2113    }
2114
2115    fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
2116        let auth = ClerkAuth::new("eyJtoken");
2117        pollster::block_on(auth.authenticate(http)).unwrap();
2118        SunoClient::new(auth, clock)
2119    }
2120
2121    fn one_clip_page(id: &str, next_cursor: Option<&str>) -> String {
2122        let mut page = serde_json::json!({
2123            "has_more": next_cursor.is_some(),
2124            "clips": [{
2125                "id": id, "title": "Song", "status": "complete",
2126                "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
2127                "metadata": {"type": "gen"}
2128            }]
2129        });
2130        if let Some(cursor) = next_cursor {
2131            page["next_cursor"] = serde_json::json!(cursor);
2132        }
2133        page.to_string()
2134    }
2135
2136    #[test]
2137    fn list_clips_retries_a_rate_limited_page() {
2138        let http = ScriptedHttp::new().with_auth().route_seq(
2139            "/api/feed/v3",
2140            vec![Reply::status(429), Reply::json(&feed_body())],
2141        );
2142        let clock = RecordingClock::new();
2143        let client = scripted_client(&http, clock.clone());
2144
2145        let (clips, complete, _) =
2146            pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2147        assert_eq!(clips.len(), 1);
2148        assert!(complete);
2149        // The throttled page was retried once, waiting the default post-429 wait.
2150        assert_eq!(http.count("/api/feed/v3"), 2);
2151        assert_eq!(clock.sleeps(), vec![Duration::from_secs(5)]);
2152    }
2153
2154    #[test]
2155    fn list_clips_honours_retry_after_on_a_throttled_page() {
2156        let http = ScriptedHttp::new().with_auth().route_seq(
2157            "/api/feed/v3",
2158            vec![
2159                Reply::status(429).with_retry_after(7),
2160                Reply::json(&feed_body()),
2161            ],
2162        );
2163        let clock = RecordingClock::new();
2164        let client = scripted_client(&http, clock.clone());
2165
2166        let (clips, _complete, _) =
2167            pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2168        assert_eq!(clips.len(), 1);
2169        // The server's Retry-After is honoured directly as the post-429 wait.
2170        assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
2171    }
2172
2173    #[test]
2174    fn list_clips_re_posts_the_same_cursor_after_a_throttled_page() {
2175        // A 429 mid-walk must re-POST the *same* cursor, not skip a page.
2176        let http = ScriptedHttp::new().with_auth().route_seq(
2177            "/api/feed/v3",
2178            vec![
2179                Reply::json(&one_clip_page("a", Some("cur1"))),
2180                Reply::status(429),
2181                Reply::json(&one_clip_page("b", None)),
2182            ],
2183        );
2184        let clock = RecordingClock::new();
2185        let client = scripted_client(&http, clock.clone());
2186
2187        let (clips, complete, _) =
2188            pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2189        assert!(complete);
2190        assert_eq!(clips.len(), 2);
2191        let bodies = http.bodies();
2192        let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
2193        assert_eq!(feed_bodies.len(), 3, "page 1, the 429 retry, then page 2");
2194        // The retry (body 2) carries the SAME cursor as the throttled call (body 2 == the
2195        // second feed POST), i.e. the cursor from page 1's next_cursor.
2196        let retried: Value = serde_json::from_str(feed_bodies[1]).unwrap();
2197        let after_retry: Value = serde_json::from_str(feed_bodies[2]).unwrap();
2198        assert_eq!(retried["cursor"], "cur1");
2199        assert_eq!(after_retry["cursor"], "cur1");
2200    }
2201
2202    #[test]
2203    fn list_clips_threads_the_cursor_across_pages() {
2204        let http = ScriptedHttp::new().with_auth().route_seq(
2205            "/api/feed/v3",
2206            vec![
2207                Reply::json(&one_clip_page("a", Some("cur1"))),
2208                Reply::json(&one_clip_page("b", None)),
2209            ],
2210        );
2211        let clock = RecordingClock::new();
2212        let client = scripted_client(&http, clock.clone());
2213
2214        let (clips, complete, _) =
2215            pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2216        assert!(complete);
2217        assert_eq!(clips.len(), 2);
2218        let bodies = http.bodies();
2219        let feed_bodies: Vec<&String> = bodies.iter().filter(|b| b.contains("filters")).collect();
2220        assert_eq!(feed_bodies.len(), 2);
2221        let page1: Value = serde_json::from_str(feed_bodies[0]).unwrap();
2222        let page2: Value = serde_json::from_str(feed_bodies[1]).unwrap();
2223        // Page 1 omits the cursor; page 2 carries exactly page 1's next_cursor.
2224        assert!(page1.get("cursor").is_none());
2225        assert_eq!(page2["cursor"], "cur1");
2226    }
2227
2228    #[test]
2229    fn list_clips_stops_incomplete_when_has_more_but_no_cursor() {
2230        // has_more == true with no usable next_cursor: a truncated feed. The walk
2231        // must stop, report incomplete, and never re-POST a null cursor.
2232        let page = serde_json::json!({
2233            "has_more": true,
2234            "clips": [{
2235                "id": "a", "title": "Song", "status": "complete",
2236                "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
2237            }]
2238        })
2239        .to_string();
2240        let http = ScriptedHttp::new()
2241            .with_auth()
2242            .route("/api/feed/v3", Reply::json(&page));
2243        let clock = RecordingClock::new();
2244        let client = scripted_client(&http, clock.clone());
2245
2246        let (clips, complete, _) =
2247            pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2248        assert!(!complete);
2249        assert_eq!(clips.len(), 1);
2250        assert_eq!(http.count("/api/feed/v3"), 1, "no re-POST of a null cursor");
2251    }
2252
2253    #[test]
2254    fn list_clips_is_incomplete_when_has_more_is_missing() {
2255        // A page with no has_more key must not be read as a fully drained feed.
2256        let page = serde_json::json!({
2257            "clips": [{
2258                "id": "a", "title": "Song", "status": "complete",
2259                "audio_url": "https://cdn1.suno.ai/a.mp3", "metadata": {"type": "gen"}
2260            }]
2261        })
2262        .to_string();
2263        let http = ScriptedHttp::new()
2264            .with_auth()
2265            .route("/api/feed/v3", Reply::json(&page));
2266        let clock = RecordingClock::new();
2267        let client = scripted_client(&http, clock.clone());
2268
2269        let (clips, complete, _) =
2270            pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2271        assert!(!complete);
2272        assert_eq!(clips.len(), 1);
2273        assert_eq!(http.count("/api/feed/v3"), 1);
2274    }
2275
2276    #[test]
2277    fn list_clips_propagates_an_error_mid_walk_and_never_completes() {
2278        let http = ScriptedHttp::new().with_auth().route_seq(
2279            "/api/feed/v3",
2280            vec![
2281                Reply::json(&one_clip_page("a", Some("cur1"))),
2282                Reply::status(500),
2283            ],
2284        );
2285        let clock = RecordingClock::new();
2286        let client = scripted_client(&http, clock.clone());
2287
2288        let result = pollster::block_on(client.list_clips(&http, false, None));
2289        assert!(matches!(result, Err(Error::Api(_))));
2290    }
2291
2292    #[test]
2293    fn list_clips_is_complete_on_an_empty_drained_feed() {
2294        // An empty but fully drained feed is authoritative (complete = true);
2295        // deletion is separately gated by there being a mirror source.
2296        let page = serde_json::json!({"has_more": false, "clips": []}).to_string();
2297        let http = ScriptedHttp::new()
2298            .with_auth()
2299            .route("/api/feed/v3", Reply::json(&page));
2300        let clock = RecordingClock::new();
2301        let client = scripted_client(&http, clock.clone());
2302
2303        let (clips, complete, _) =
2304            pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2305        assert!(complete);
2306        assert!(clips.is_empty());
2307    }
2308
2309    #[test]
2310    fn list_clips_flags_filter_loss_on_a_drained_feed() {
2311        // A fully drained feed that still hides a clip behind is_downloadable
2312        // must report any_filtered=true, so the Library/Liked area is not
2313        // authoritative and an irreplaceable master is never deleted as
2314        // "absent" (#248).
2315        let http = ScriptedHttp::new()
2316            .with_auth()
2317            .route("/api/feed/v3", Reply::json(&feed_body()));
2318        let clock = RecordingClock::new();
2319        let client = scripted_client(&http, clock.clone());
2320
2321        let (clips, complete, any_filtered) =
2322            pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2323        assert!(complete);
2324        assert!(any_filtered);
2325        assert_eq!(clips.len(), 1);
2326    }
2327
2328    #[test]
2329    fn list_clips_ors_filter_loss_across_pages() {
2330        // The first page loses nothing; the second hides a streaming clip. The
2331        // flag must accumulate so a late-page filter loss still disarms deletion.
2332        let page2 = serde_json::json!({
2333            "has_more": false,
2334            "clips": [
2335                {"id": "e", "status": "complete", "metadata": {"type": "gen"}},
2336                {"id": "f", "status": "streaming", "metadata": {}}
2337            ]
2338        })
2339        .to_string();
2340        let http = ScriptedHttp::new().with_auth().route_seq(
2341            "/api/feed/v3",
2342            vec![
2343                Reply::json(&one_clip_page("a", Some("cur1"))),
2344                Reply::json(&page2),
2345            ],
2346        );
2347        let clock = RecordingClock::new();
2348        let client = scripted_client(&http, clock.clone());
2349
2350        let (clips, complete, any_filtered) =
2351            pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2352        assert!(complete);
2353        assert!(any_filtered);
2354        // "a" and "e" survive; the streaming "f" is dropped.
2355        assert_eq!(clips.len(), 2);
2356    }
2357
2358    #[test]
2359    fn list_clips_liked_scope_sends_the_liked_filter() {
2360        let http = ScriptedHttp::new()
2361            .with_auth()
2362            .route("/api/feed/v3", Reply::json(&feed_body()));
2363        let clock = RecordingClock::new();
2364        let client = scripted_client(&http, clock.clone());
2365
2366        let _ = pollster::block_on(client.list_clips(&http, true, None)).unwrap();
2367        let bodies = http.bodies();
2368        let feed_body = bodies.iter().find(|b| b.contains("filters")).unwrap();
2369        let value: Value = serde_json::from_str(feed_body).unwrap();
2370        assert_eq!(value["filters"]["liked"], "True");
2371        assert_eq!(value["filters"]["trashed"], "False");
2372    }
2373
2374    #[test]
2375    fn list_clips_does_not_pace_an_unthrottled_walk() {
2376        let http = ScriptedHttp::new().with_auth().route_seq(
2377            "/api/feed/v3",
2378            vec![
2379                Reply::json(&one_clip_page("a", Some("cur1"))),
2380                Reply::json(&one_clip_page("e", None)),
2381            ],
2382        );
2383        let clock = RecordingClock::new();
2384        let client = scripted_client(&http, clock.clone());
2385
2386        let (clips, complete, _) =
2387            pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2388        assert!(complete);
2389        assert_eq!(clips.len(), 2);
2390        assert_eq!(http.count("/api/feed/v3"), 2);
2391        // Pacing is reactive: with no 429 the whole walk waits nowhere.
2392        assert!(clock.sleeps().is_empty());
2393    }
2394
2395    #[test]
2396    fn list_clips_slows_its_pace_after_a_throttled_page() {
2397        let http = ScriptedHttp::new().with_auth().route_seq(
2398            "/api/feed/v3",
2399            vec![
2400                Reply::status(429),
2401                Reply::json(&one_clip_page("a", Some("cur1"))),
2402                Reply::json(&one_clip_page("e", None)),
2403            ],
2404        );
2405        let clock = RecordingClock::new();
2406        let client = scripted_client(&http, clock.clone());
2407
2408        let (clips, complete, _) =
2409            pollster::block_on(client.list_clips(&http, false, None)).unwrap();
2410        assert!(complete);
2411        assert_eq!(clips.len(), 2);
2412        // The 429 halved the rate, so the default post-429 wait is followed by a
2413        // doubled inter-page pace (500ms to 1s) for the next page.
2414        assert_eq!(
2415            clock.sleeps(),
2416            vec![Duration::from_secs(5), Duration::from_secs(1)]
2417        );
2418    }
2419
2420    #[test]
2421    fn list_clips_gives_up_after_max_retries() {
2422        let http = ScriptedHttp::new()
2423            .with_auth()
2424            .route("/api/feed/v3", Reply::status(429));
2425        let clock = RecordingClock::new();
2426        let client = scripted_client(&http, clock.clone());
2427
2428        let result = pollster::block_on(client.list_clips(&http, false, None));
2429        assert!(matches!(result, Err(Error::RateLimited { .. })));
2430        let budget = crate::consts::API_MAX_RETRIES as usize;
2431        assert_eq!(clock.sleeps().len(), budget);
2432        assert_eq!(http.count("/api/feed/v3"), budget + 1);
2433    }
2434
2435    #[test]
2436    fn parse_clip_accepts_bare_and_wrapped_shapes() {
2437        let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
2438        assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
2439
2440        let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
2441        assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
2442
2443        let missing = serde_json::json!({"detail": "not found"}).to_string();
2444        assert!(parse_clip(missing.as_bytes()).is_none());
2445    }
2446
2447    #[test]
2448    fn get_clip_uses_the_dedicated_endpoint() {
2449        let clip_body = serde_json::json!({
2450            "id": "z", "title": "Zed", "status": "complete",
2451            "audio_url": "https://cdn1.suno.ai/z.mp3",
2452            "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
2453        })
2454        .to_string();
2455        let mut rules = auth_rules();
2456        rules.push(Rule::new("/api/clip/", 200, clip_body));
2457        let http = MockHttp::new(rules);
2458        let client = authed_client(&http);
2459
2460        let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
2461        assert_eq!(clip.id, "z");
2462        assert_eq!(clip.title, "Zed");
2463        assert_eq!(clip.tags, "jazz");
2464    }
2465
2466    #[test]
2467    fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
2468        let mut rules = auth_rules();
2469        rules.push(Rule::new(
2470            "/api/clip/",
2471            404,
2472            r#"{"detail": "not found"}"#.to_string(),
2473        ));
2474        rules.push(Rule::new("/api/feed/v3", 200, feed_body()));
2475        let http = MockHttp::new(rules);
2476        let client = authed_client(&http);
2477
2478        let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
2479        assert_eq!(clip.id, "a");
2480        assert_eq!(clip.tags, "rock");
2481    }
2482
2483    #[test]
2484    fn request_wav_accepts_a_2xx_status() {
2485        let mut rules = auth_rules();
2486        rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
2487        let http = MockHttp::new(rules);
2488        let client = authed_client(&http);
2489
2490        assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
2491    }
2492
2493    #[test]
2494    fn wav_url_reads_the_ready_url() {
2495        let mut rules = auth_rules();
2496        rules.push(Rule::new(
2497            "/wav_file/",
2498            200,
2499            r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
2500        ));
2501        let http = MockHttp::new(rules);
2502        let client = authed_client(&http);
2503
2504        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
2505        assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
2506    }
2507
2508    #[test]
2509    fn wav_url_is_none_until_the_render_is_ready() {
2510        let mut rules = auth_rules();
2511        rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
2512        let http = MockHttp::new(rules);
2513        let client = authed_client(&http);
2514
2515        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
2516        assert_eq!(url, None);
2517    }
2518
2519    #[test]
2520    fn wav_url_404_maps_to_none() {
2521        // A 404 means the render is absent or was never requested, not a run
2522        // failure: map it to None, symmetric with aligned_lyrics, so the fetch
2523        // flow polls again rather than aborting the whole render.
2524        let mut rules = auth_rules();
2525        rules.push(Rule::new(
2526            "/wav_file/",
2527            404,
2528            r#"{"detail": "Not found."}"#.to_string(),
2529        ));
2530        let http = MockHttp::new(rules);
2531        let client = authed_client(&http);
2532
2533        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
2534        assert_eq!(url, None);
2535    }
2536
2537    #[test]
2538    fn get_clips_by_ids_keeps_infill_and_upload_ancestors() {
2539        // The gap-fill path must not apply the listing's downloadability filter:
2540        // an infill ancestor and an upload root both survive, returned by the
2541        // batch `get_songs_by_ids` call.
2542        let p1 = serde_json::json!({
2543            "id": "p1", "title": "Infill Ancestor", "status": "complete",
2544            "metadata": {"type": "gen", "task": "infill"}
2545        })
2546        .to_string();
2547        let p2 = serde_json::json!({
2548            "id": "p2", "title": "Uploaded Root", "status": "complete",
2549            "metadata": {"type": "upload"}
2550        })
2551        .to_string();
2552        let batch = format!(r#"{{"clips":[{p1},{p2}]}}"#);
2553        let mut rules = auth_rules();
2554        rules.push(Rule::new("get_songs_by_ids", 200, batch));
2555        rules.push(Rule::new("/api/clip/p1", 200, p1));
2556        rules.push(Rule::new("/api/clip/p2", 200, p2));
2557        let http = MockHttp::new(rules);
2558        let client = authed_client(&http);
2559
2560        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"], 4)).unwrap();
2561        assert_eq!(
2562            clips.len(),
2563            2,
2564            "infill and upload ancestors must not be filtered"
2565        );
2566        assert_eq!(clips[0].id, "p1");
2567        assert_eq!(clips[1].id, "p2");
2568    }
2569
2570    #[test]
2571    fn get_clips_by_ids_returns_a_trashed_clip() {
2572        // A trashed ancestor must still be retrievable by id (the v2 `?ids=`
2573        // capability that `get_songs_by_ids` now restores in one request).
2574        let trashed = serde_json::json!({
2575            "id": "t1", "title": "Trashed Ancestor", "status": "complete",
2576            "is_trashed": true, "metadata": {"type": "gen"}
2577        })
2578        .to_string();
2579        let batch = format!(r#"{{"clips":[{trashed}]}}"#);
2580        let mut rules = auth_rules();
2581        rules.push(Rule::new("get_songs_by_ids", 200, batch));
2582        rules.push(Rule::new("/api/clip/t1", 200, trashed));
2583        let http = MockHttp::new(rules);
2584        let client = authed_client(&http);
2585
2586        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["t1"], 4)).unwrap();
2587        assert_eq!(clips.len(), 1);
2588        assert_eq!(clips[0].id, "t1");
2589        assert!(clips[0].is_trashed);
2590    }
2591
2592    #[test]
2593    fn get_clips_by_ids_skips_a_not_found_id_and_dedupes() {
2594        let only = serde_json::json!({
2595            "id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}
2596        })
2597        .to_string();
2598        // The batch returns "only" and omits "gone"; "gone" then falls back to a
2599        // per-id fetch that 404s and is skipped.
2600        let batch = format!(r#"{{"clips":[{only}]}}"#);
2601        let http = ScriptedHttp::new()
2602            .with_auth()
2603            .route("get_songs_by_ids", Reply::json(&batch))
2604            .route("/api/clip/gone", Reply::status(404));
2605        let client = scripted_client(&http, RecordingClock::new());
2606
2607        let clips =
2608            pollster::block_on(client.get_clips_by_ids(&http, &["only", "gone", "only"], 4))
2609                .unwrap();
2610        assert_eq!(clips.len(), 1, "the 404 id is skipped");
2611        assert_eq!(clips[0].id, "only");
2612        // "only" is deduped and returned by the batch, so it is never per-id
2613        // fetched; "gone" is attempted once via the per-id fallback.
2614        assert_eq!(
2615            http.count("get_songs_by_ids"),
2616            1,
2617            "one batch call for both ids"
2618        );
2619        assert_eq!(http.count("/api/clip/only"), 0);
2620        assert_eq!(http.count("/api/clip/gone"), 1);
2621    }
2622
2623    #[test]
2624    fn get_clips_by_ids_matches_serial_results_and_keeps_order_when_concurrent() {
2625        // With no batch route the batch is unavailable, so both calls fall back
2626        // to per-id and must return the deduped input order regardless of the
2627        // concurrency used.
2628        let a = serde_json::json!({
2629            "id": "a", "title": "A", "status": "complete", "metadata": {"type": "gen"}
2630        })
2631        .to_string();
2632        let b = serde_json::json!({
2633            "id": "b", "title": "B", "status": "complete", "metadata": {"type": "gen"}
2634        })
2635        .to_string();
2636        let c = serde_json::json!({
2637            "id": "c", "title": "C", "status": "complete", "metadata": {"type": "gen"}
2638        })
2639        .to_string();
2640        let http = ScriptedHttp::new()
2641            .with_auth()
2642            .route("/api/clip/a", Reply::json(&a))
2643            .route("/api/clip/b", Reply::json(&b))
2644            .route("/api/clip/c", Reply::json(&c));
2645        let client = scripted_client(&http, RecordingClock::new());
2646        let ids = ["b", "a", "c", "a"];
2647
2648        let serial = pollster::block_on(client.get_clips_by_ids(&http, &ids, 1)).unwrap();
2649        let concurrent = pollster::block_on(client.get_clips_by_ids(&http, &ids, 4)).unwrap();
2650
2651        let serial_ids: Vec<&str> = serial.iter().map(|clip| clip.id.as_str()).collect();
2652        let concurrent_ids: Vec<&str> = concurrent.iter().map(|clip| clip.id.as_str()).collect();
2653        assert_eq!(serial_ids, vec!["b", "a", "c"]);
2654        assert_eq!(concurrent_ids, serial_ids);
2655    }
2656
2657    /// A minimal complete-clip body for the batch tests below.
2658    fn clip_body(id: &str) -> String {
2659        format!(r#"{{"id":"{id}","title":"T","status":"complete","metadata":{{"type":"gen"}}}}"#)
2660    }
2661
2662    #[test]
2663    fn get_songs_by_ids_maps_the_batch_body_matched_by_id_in_input_order() {
2664        // The batch returns the clips out of order; the result must follow the
2665        // de-duplicated input order, matched by id, never the response position.
2666        let batch = format!(
2667            r#"{{"clips":[{},{},{}]}}"#,
2668            clip_body("c"),
2669            clip_body("a"),
2670            clip_body("b")
2671        );
2672        let http = ScriptedHttp::new()
2673            .with_auth()
2674            .route("get_songs_by_ids", Reply::json(&batch));
2675        let client = scripted_client(&http, RecordingClock::new());
2676
2677        let clips =
2678            pollster::block_on(client.get_songs_by_ids(&http, &["a", "b", "c", "a"])).unwrap();
2679        let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2680        assert_eq!(ids, vec!["a", "b", "c"], "input order, not response order");
2681        assert_eq!(http.count("get_songs_by_ids"), 1, "one chunk, one request");
2682    }
2683
2684    #[test]
2685    fn get_songs_by_ids_drops_clips_that_were_not_requested() {
2686        // A defensive body carrying an extra id must not leak into the result.
2687        let batch = format!(r#"{{"clips":[{},{}]}}"#, clip_body("a"), clip_body("x"));
2688        let http = ScriptedHttp::new()
2689            .with_auth()
2690            .route("get_songs_by_ids", Reply::json(&batch));
2691        let client = scripted_client(&http, RecordingClock::new());
2692
2693        let clips = pollster::block_on(client.get_songs_by_ids(&http, &["a"])).unwrap();
2694        let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2695        assert_eq!(ids, vec!["a"], "an unrequested id is dropped");
2696    }
2697
2698    #[test]
2699    fn get_songs_by_ids_chunks_ids_beyond_the_chunk_size() {
2700        // 21 ids span two chunks (20 + 1), one batch request each, with the
2701        // input order preserved across the chunk boundary.
2702        let ids: Vec<String> = (0..21).map(|i| format!("id-{i:02}")).collect();
2703        let body = |slice: &[String]| {
2704            let clips: Vec<String> = slice.iter().map(|id| clip_body(id)).collect();
2705            format!(r#"{{"clips":[{}]}}"#, clips.join(","))
2706        };
2707        let http = ScriptedHttp::new().with_auth().route_seq(
2708            "get_songs_by_ids",
2709            vec![
2710                Reply::json(&body(&ids[..20])),
2711                Reply::json(&body(&ids[20..])),
2712            ],
2713        );
2714        let client = scripted_client(&http, RecordingClock::new());
2715        let refs: Vec<&str> = ids.iter().map(String::as_str).collect();
2716
2717        let clips = pollster::block_on(client.get_songs_by_ids(&http, &refs)).unwrap();
2718        let got: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2719        assert_eq!(got, refs, "all 21 ids returned in input order");
2720        assert_eq!(
2721            http.count("get_songs_by_ids"),
2722            2,
2723            "two chunks -> two requests"
2724        );
2725        let batch_calls: Vec<String> = http
2726            .calls()
2727            .into_iter()
2728            .filter(|url| url.contains("get_songs_by_ids"))
2729            .collect();
2730        assert_eq!(
2731            batch_calls[0].matches("ids=").count(),
2732            20,
2733            "first chunk of 20"
2734        );
2735        assert_eq!(
2736            batch_calls[1].matches("ids=").count(),
2737            1,
2738            "second chunk of 1"
2739        );
2740    }
2741
2742    #[test]
2743    fn get_clips_by_ids_batch_first_does_not_fetch_per_id_when_batch_is_complete() {
2744        // When the batch returns every requested id, no per-id request is made.
2745        let batch = format!(r#"{{"clips":[{},{}]}}"#, clip_body("a"), clip_body("b"));
2746        let http = ScriptedHttp::new()
2747            .with_auth()
2748            .route("get_songs_by_ids", Reply::json(&batch))
2749            .route("/api/clip/a", Reply::json(&clip_body("a")))
2750            .route("/api/clip/b", Reply::json(&clip_body("b")));
2751        let client = scripted_client(&http, RecordingClock::new());
2752
2753        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4)).unwrap();
2754        let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2755        assert_eq!(ids, vec!["a", "b"]);
2756        assert_eq!(http.count("get_songs_by_ids"), 1);
2757        assert_eq!(
2758            http.count("/api/clip/"),
2759            0,
2760            "a complete batch needs no per-id fallback"
2761        );
2762    }
2763
2764    #[test]
2765    fn get_clips_by_ids_fills_ids_the_batch_omits_via_per_id() {
2766        // The batch returns only "a"; "b" is filled by a per-id fetch.
2767        let batch = format!(r#"{{"clips":[{}]}}"#, clip_body("a"));
2768        let http = ScriptedHttp::new()
2769            .with_auth()
2770            .route("get_songs_by_ids", Reply::json(&batch))
2771            .route("/api/clip/b", Reply::json(&clip_body("b")));
2772        let client = scripted_client(&http, RecordingClock::new());
2773
2774        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4)).unwrap();
2775        let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2776        assert_eq!(ids, vec!["a", "b"], "omitted id is filled, order preserved");
2777        assert_eq!(http.count("/api/clip/a"), 0, "a came from the batch");
2778        assert_eq!(http.count("/api/clip/b"), 1, "b was filled per-id");
2779    }
2780
2781    #[test]
2782    fn get_clips_by_ids_falls_back_to_per_id_on_a_malformed_batch_body() {
2783        // A 200 body that is not `{"clips":[…]}` yields nothing for the chunk, so
2784        // every requested id is recovered by the per-id fallback.
2785        let http = ScriptedHttp::new()
2786            .with_auth()
2787            .route("get_songs_by_ids", Reply::json("not-json{"))
2788            .route("/api/clip/a", Reply::json(&clip_body("a")))
2789            .route("/api/clip/b", Reply::json(&clip_body("b")));
2790        let client = scripted_client(&http, RecordingClock::new());
2791
2792        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4)).unwrap();
2793        let ids: Vec<&str> = clips.iter().map(|clip| clip.id.as_str()).collect();
2794        assert_eq!(ids, vec!["a", "b"]);
2795        assert_eq!(http.count("/api/clip/a"), 1);
2796        assert_eq!(http.count("/api/clip/b"), 1);
2797    }
2798
2799    #[test]
2800    fn get_clips_by_ids_propagates_a_batch_rate_limit_without_per_id_fan_out() {
2801        // A 429 that survives the retry budget propagates: it must never fan out
2802        // into a burst of per-id requests that would only deepen the throttling.
2803        let http = ScriptedHttp::new()
2804            .with_auth()
2805            .route("get_songs_by_ids", Reply::status(429))
2806            .route("/api/clip/a", Reply::json(&clip_body("a")))
2807            .route("/api/clip/b", Reply::json(&clip_body("b")));
2808        let client = scripted_client(&http, RecordingClock::new());
2809
2810        let result = pollster::block_on(client.get_clips_by_ids(&http, &["a", "b"], 4));
2811        assert!(
2812            matches!(result, Err(Error::RateLimited { .. })),
2813            "an exhausted 429 propagates"
2814        );
2815        assert_eq!(
2816            http.count("/api/clip/"),
2817            0,
2818            "no per-id fan-out on rate-limit exhaustion"
2819        );
2820    }
2821
2822    #[test]
2823    fn concurrent_reads_share_aggregate_pacing_after_first_rate_limit() {
2824        // Batch-first: one `get_songs_by_ids` request (here returning nothing)
2825        // then four concurrent per-id fallbacks. All five share the 1 req/s
2826        // aggregate pacing, so from the first to the last reserved slot they span
2827        // ~4s, with a small tolerance for runtime scheduling jitter.
2828        const EXPECTED_SPAN: Duration = Duration::from_secs(4);
2829        const TOLERANCE: Duration = Duration::from_millis(50);
2830        let ids = ["a", "b", "c", "d"];
2831        let a =
2832            serde_json::json!({"id":"a","title":"A","status":"complete","metadata":{"type":"gen"}})
2833                .to_string();
2834        let b =
2835            serde_json::json!({"id":"b","title":"B","status":"complete","metadata":{"type":"gen"}})
2836                .to_string();
2837        let c =
2838            serde_json::json!({"id":"c","title":"C","status":"complete","metadata":{"type":"gen"}})
2839                .to_string();
2840        let d =
2841            serde_json::json!({"id":"d","title":"D","status":"complete","metadata":{"type":"gen"}})
2842                .to_string();
2843        let http = ScriptedHttp::new()
2844            .with_auth()
2845            .route_seq(
2846                "/api/feed/v3",
2847                vec![
2848                    Reply::status(429),
2849                    Reply::json(&one_clip_page("seed", None)),
2850                ],
2851            )
2852            .route("get_songs_by_ids", Reply::json(r#"{"clips":[]}"#))
2853            .route("/api/clip/a", Reply::json(&a))
2854            .route("/api/clip/b", Reply::json(&b))
2855            .route("/api/clip/c", Reply::json(&c))
2856            .route("/api/clip/d", Reply::json(&d));
2857        let clock = RecordingClock::new();
2858        let client = scripted_client(&http, clock.clone());
2859        pollster::block_on(client.list_clips(&http, false, Some(1))).unwrap();
2860        let before = clock.sleeps().len();
2861
2862        let clips = pollster::block_on(client.get_clips_by_ids(&http, &ids, ids.len())).unwrap();
2863        assert_eq!(clips.len(), ids.len());
2864        let sleeps = clock.sleeps();
2865        let paced = &sleeps[before..];
2866        assert_eq!(
2867            paced.len(),
2868            ids.len() + 1,
2869            "one batch call plus four per-id"
2870        );
2871        let min = paced.iter().copied().min().unwrap();
2872        let max = paced.iter().copied().max().unwrap();
2873        let span = max.saturating_sub(min);
2874        // After the first 429, rate halves from 2 -> 1 req/s. Under shared slot
2875        // pacing, the batch call and the four per-id fallbacks are dispatched one
2876        // second apart in aggregate, so the first-to-last spacing is about four
2877        // seconds.
2878        assert!(span >= EXPECTED_SPAN.saturating_sub(TOLERANCE));
2879        assert!(span <= EXPECTED_SPAN + TOLERANCE);
2880    }
2881
2882    #[test]
2883    fn get_clip_parent_reads_the_parent_clip() {
2884        let parent = serde_json::json!({
2885            "id": "par", "title": "Ancestor", "status": "complete",
2886            "metadata": {"type": "gen"}
2887        })
2888        .to_string();
2889        let mut rules = auth_rules();
2890        rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
2891        let http = MockHttp::new(rules);
2892        let client = authed_client(&http);
2893
2894        let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
2895        assert_eq!(clip.unwrap().id, "par");
2896    }
2897
2898    #[test]
2899    fn get_clip_parent_is_none_for_a_root() {
2900        let mut rules = auth_rules();
2901        rules.push(Rule::new(
2902            "/api/clips/parent",
2903            404,
2904            r#"{"detail": "no parent"}"#.to_string(),
2905        ));
2906        let http = MockHttp::new(rules);
2907        let client = authed_client(&http);
2908
2909        let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
2910        assert!(clip.is_none());
2911    }
2912
2913    #[test]
2914    fn get_clip_parent_is_none_for_a_200_no_id_root() {
2915        // The live "no parent" contract: HTTP 200 with a bodiless clip that has
2916        // no id (`{"is_public": false}`), not a 404. parse_clip gates on a
2917        // non-empty id, so it maps to Ok(None) rather than a bogus edge. Both
2918        // the bare and `{"clip": ...}`-wrapped encodings must behave the same.
2919        for body in [
2920            r#"{"is_public": false}"#,
2921            r#"{"clip": {"is_public": false}}"#,
2922        ] {
2923            let mut rules = auth_rules();
2924            rules.push(Rule::new("/api/clips/parent", 200, body.to_string()));
2925            let http = MockHttp::new(rules);
2926            let client = authed_client(&http);
2927
2928            let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
2929            assert!(clip.is_none(), "200-no-id body {body:?} must map to None");
2930        }
2931    }
2932
2933    #[test]
2934    fn get_clip_parent_reads_the_reduced_user_prefixed_shape() {
2935        // The parent endpoint returns a reduced shape with user_-prefixed
2936        // identity keys; after the dual-identity mapper fix the parent Clip
2937        // carries a non-empty display_name/handle (regression pin for #220).
2938        let parent = serde_json::json!({
2939            "id": "00000000-0000-4000-8000-000000000020",
2940            "title": "Track 2",
2941            "is_public": false,
2942            "user_display_name": "Example Artist 4",
2943            "user_handle": "example-artist-1",
2944            "user_avatar_image_url": "https://cdn1.suno.ai/avatar.jpg"
2945        })
2946        .to_string();
2947        let mut rules = auth_rules();
2948        rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
2949        let http = MockHttp::new(rules);
2950        let client = authed_client(&http);
2951
2952        let clip = pollster::block_on(client.get_clip_parent(&http, "child"))
2953            .unwrap()
2954            .expect("a parent clip with an id");
2955        assert_eq!(clip.id, "00000000-0000-4000-8000-000000000020");
2956        assert_eq!(clip.display_name, "Example Artist 4");
2957        assert_eq!(clip.handle, "example-artist-1");
2958        assert_eq!(clip.avatar_image_url, "https://cdn1.suno.ai/avatar.jpg");
2959    }
2960
2961    #[test]
2962    fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
2963        // A transient 5xx must never be mistaken for "this clip is a root":
2964        // folding it into Ok(None) would fabricate a wrong external root and let
2965        // a blip rewrite lineage (HARDENING H3). Only a real 404 means no parent.
2966        for status in [500u16, 503] {
2967            let mut rules = auth_rules();
2968            rules.push(Rule::new(
2969                "/api/clips/parent",
2970                status,
2971                r#"{"detail": "server error"}"#.to_string(),
2972            ));
2973            let http = MockHttp::new(rules);
2974            let client = authed_client(&http);
2975
2976            let result = pollster::block_on(client.get_clip_parent(&http, "child"));
2977            assert!(
2978                matches!(result, Err(Error::Api(_))),
2979                "status {status} must propagate as an error, not Ok(None)"
2980            );
2981        }
2982    }
2983
2984    #[test]
2985    fn get_playlists_maps_entries_and_skips_missing_ids() {
2986        let page1 = serde_json::json!({
2987            "playlists": [
2988                {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
2989                {"id": "", "name": "No Id", "num_total_results": 3},
2990                {"name": "Also No Id"}
2991            ]
2992        })
2993        .to_string();
2994        let mut rules = auth_rules();
2995        // Page 1 returns entries; page 2 is empty, ending pagination.
2996        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
2997        rules.push(Rule::new(
2998            "/api/playlist/me?page=2",
2999            200,
3000            r#"{"playlists": []}"#.to_string(),
3001        ));
3002        let http = MockHttp::new(rules);
3003        let client = authed_client(&http);
3004
3005        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
3006        assert_eq!(playlists.len(), 1, "entries without an id are dropped");
3007        assert_eq!(
3008            playlists[0],
3009            Playlist {
3010                id: "pl1".to_owned(),
3011                name: "Road Trip".to_owned(),
3012                num_clips: 12,
3013            }
3014        );
3015    }
3016
3017    #[test]
3018    fn get_playlists_defaults_a_missing_name_to_untitled() {
3019        let page1 = serde_json::json!({
3020            "playlists": [{"id": "pl9", "num_total_results": 1}]
3021        })
3022        .to_string();
3023        let mut rules = auth_rules();
3024        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
3025        rules.push(Rule::new(
3026            "/api/playlist/me?page=2",
3027            200,
3028            r#"{"playlists": []}"#.to_string(),
3029        ));
3030        let http = MockHttp::new(rules);
3031        let client = authed_client(&http);
3032
3033        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
3034        assert_eq!(playlists[0].name, "Untitled");
3035    }
3036
3037    #[test]
3038    fn get_playlist_clips_preserves_order_and_unwraps_clip() {
3039        // Members arrive wrapped under `clip`, in playlist order, already
3040        // non-trashed. Order is preserved and no downloadability filter is applied.
3041        let body = serde_json::json!({
3042            "num_total_results": 2,
3043            "playlist_clips": [
3044                {"clip": {
3045                    "id": "second", "title": "Second", "status": "complete",
3046                    "metadata": {"duration": 60.0, "type": "gen"}
3047                }},
3048                {"clip": {
3049                    "id": "first", "title": "First", "status": "complete",
3050                    "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
3051                }}
3052            ]
3053        })
3054        .to_string();
3055        let mut rules = auth_rules();
3056        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3057        let http = MockHttp::new(rules);
3058        let client = authed_client(&http);
3059
3060        let (clips, complete) =
3061            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3062        assert_eq!(clips.len(), 2, "an infill member is not filtered out");
3063        assert_eq!(clips[0].id, "second");
3064        assert_eq!(clips[1].id, "first");
3065        assert!(
3066            complete,
3067            "returned == num_total_results is fully enumerated"
3068        );
3069    }
3070
3071    #[test]
3072    fn get_playlist_clips_short_page_is_not_complete() {
3073        // A page with fewer entries than num_total_results is not authoritative.
3074        let body = serde_json::json!({
3075            "num_total_results": 5,
3076            "playlist_clips": [
3077                {"clip": {
3078                    "id": "only", "title": "Only", "status": "complete",
3079                    "metadata": {"duration": 60.0, "type": "gen"}
3080                }}
3081            ]
3082        })
3083        .to_string();
3084        let mut rules = auth_rules();
3085        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3086        let http = MockHttp::new(rules);
3087        let client = authed_client(&http);
3088
3089        let (clips, complete) =
3090            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3091        assert_eq!(clips.len(), 1);
3092        assert!(!complete, "a short page is not fully enumerated");
3093    }
3094
3095    #[test]
3096    fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
3097        let mut rules = auth_rules();
3098        rules.push(Rule::new(
3099            "/api/playlist/empty/",
3100            200,
3101            r#"{"num_total_results": 0, "playlist_clips": []}"#.to_string(),
3102        ));
3103        let http = MockHttp::new(rules);
3104        let client = authed_client(&http);
3105
3106        let (clips, complete) =
3107            pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
3108        assert!(clips.is_empty());
3109        assert!(
3110            complete,
3111            "an empty playlist reporting zero total is complete"
3112        );
3113    }
3114
3115    #[test]
3116    fn get_playlist_clips_missing_total_is_not_complete() {
3117        // A body without num_total_results cannot be verified as whole, so it is
3118        // never authoritative -- an empty or malformed page must not let a Mirror
3119        // area delete from it (D5).
3120        let mut rules = auth_rules();
3121        rules.push(Rule::new(
3122            "/api/playlist/pl1/",
3123            200,
3124            r#"{"playlist_clips": []}"#.to_string(),
3125        ));
3126        let http = MockHttp::new(rules);
3127        let client = authed_client(&http);
3128
3129        let (clips, complete) =
3130            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3131        assert!(clips.is_empty());
3132        assert!(!complete, "a missing total is never fully enumerated");
3133    }
3134
3135    #[test]
3136    fn get_playlist_clips_dropped_member_disarms_authority() {
3137        // A member whose clip carries no usable id is dropped by the empty-id
3138        // filter, so clips.len() < raw_len even when raw_len == num_total_results.
3139        // Both a missing `id` key and an empty-string `id` must disarm deletion
3140        // authority rather than silently arming a Mirror area on a short set.
3141        let missing_id = serde_json::json!({
3142            "num_total_results": 2,
3143            "playlist_clips": [
3144                {"clip": {
3145                    "id": "a", "title": "A", "status": "complete",
3146                    "metadata": {"duration": 60.0, "type": "gen"}
3147                }},
3148                {"clip": {
3149                    "title": "No Id", "status": "complete",
3150                    "metadata": {"duration": 30.0, "type": "gen"}
3151                }}
3152            ]
3153        })
3154        .to_string();
3155        let empty_id = serde_json::json!({
3156            "num_total_results": 2,
3157            "playlist_clips": [
3158                {"clip": {
3159                    "id": "a", "title": "A", "status": "complete",
3160                    "metadata": {"duration": 60.0, "type": "gen"}
3161                }},
3162                {"clip": {
3163                    "id": "", "title": "Empty Id", "status": "complete",
3164                    "metadata": {"duration": 30.0, "type": "gen"}
3165                }}
3166            ]
3167        })
3168        .to_string();
3169        for body in [missing_id, empty_id] {
3170            let mut rules = auth_rules();
3171            rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3172            let http = MockHttp::new(rules);
3173            let client = authed_client(&http);
3174
3175            let (clips, complete) =
3176                pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3177            assert_eq!(clips.len(), 1, "the member with no id is dropped");
3178            assert!(
3179                !complete,
3180                "a dropped member disarms authority even when raw_len == total"
3181            );
3182        }
3183    }
3184
3185    #[test]
3186    fn get_playlist_clips_over_count_is_not_complete() {
3187        // total=2 but three raw members (one with an empty id): clips.len()==2
3188        // matches the total, yet raw_len==3 does not. The two-conjunct gate must
3189        // reject this; a mis-simplification to `clips.len() == total` would wrongly
3190        // arm authority here.
3191        let body = serde_json::json!({
3192            "num_total_results": 2,
3193            "playlist_clips": [
3194                {"clip": {
3195                    "id": "a", "title": "A", "status": "complete",
3196                    "metadata": {"duration": 60.0, "type": "gen"}
3197                }},
3198                {"clip": {
3199                    "id": "b", "title": "B", "status": "complete",
3200                    "metadata": {"duration": 30.0, "type": "gen"}
3201                }},
3202                {"clip": {
3203                    "id": "", "title": "Empty Id", "status": "complete",
3204                    "metadata": {"duration": 45.0, "type": "gen"}
3205                }}
3206            ]
3207        })
3208        .to_string();
3209        let mut rules = auth_rules();
3210        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3211        let http = MockHttp::new(rules);
3212        let client = authed_client(&http);
3213
3214        let (clips, complete) =
3215            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3216        assert_eq!(clips.len(), 2, "the empty-id member is dropped");
3217        assert!(
3218            !complete,
3219            "raw_len (3) diverging from the total (2) is not authoritative"
3220        );
3221    }
3222
3223    #[test]
3224    fn get_playlist_clips_ignores_song_count() {
3225        // The detail reports song_count=0 while num_total_results=1 for the same
3226        // playlist; completeness must trust num_total_results, so a single-member
3227        // page reads as complete instead of being compared against song_count.
3228        let body = serde_json::json!({
3229            "num_total_results": 1,
3230            "song_count": 0,
3231            "playlist_clips": [
3232                {"clip": {
3233                    "id": "only", "title": "Only", "status": "complete",
3234                    "metadata": {"duration": 60.0, "type": "gen"}
3235                }}
3236            ]
3237        })
3238        .to_string();
3239        let mut rules = auth_rules();
3240        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3241        let http = MockHttp::new(rules);
3242        let client = authed_client(&http);
3243
3244        let (clips, complete) =
3245            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3246        assert_eq!(clips.len(), 1);
3247        assert!(
3248            complete,
3249            "completeness uses num_total_results, not song_count"
3250        );
3251    }
3252
3253    #[test]
3254    fn get_playlists_num_clips_ignores_song_count() {
3255        // song_count is unreliable across endpoints (15 in the listing, 0 in the
3256        // detail), so num_clips must come from num_total_results, never song_count.
3257        let page1 = serde_json::json!({
3258            "playlists": [
3259                {"id": "pl1", "name": "Road Trip", "num_total_results": 15, "song_count": 0}
3260            ]
3261        })
3262        .to_string();
3263        let mut rules = auth_rules();
3264        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
3265        rules.push(Rule::new(
3266            "/api/playlist/me?page=2",
3267            200,
3268            r#"{"playlists": []}"#.to_string(),
3269        ));
3270        let http = MockHttp::new(rules);
3271        let client = authed_client(&http);
3272
3273        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
3274        assert_eq!(
3275            playlists[0].num_clips, 15,
3276            "num_clips reads num_total_results, not song_count"
3277        );
3278    }
3279
3280    #[test]
3281    fn get_playlists_dedupes_a_page_ignoring_server() {
3282        // A server that ignores `page` returns the same non-empty body for every
3283        // page, so the empty-page terminator never fires and MAX_PAGES bounds the
3284        // loop. Dedupe-by-id keeps the result to the true unique set instead of
3285        // MAX_PAGES copies.
3286        let same_body = serde_json::json!({
3287            "playlists": [
3288                {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
3289                {"id": "pl2", "name": "Chill", "num_total_results": 7}
3290            ]
3291        })
3292        .to_string();
3293        let mut rules = auth_rules();
3294        rules.push(Rule::new("/api/playlist/me", 200, same_body));
3295        let http = MockHttp::new(rules);
3296        let client = authed_client(&http);
3297
3298        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
3299        assert_eq!(
3300            playlists.len(),
3301            2,
3302            "duplicates from a page-ignoring server are collapsed"
3303        );
3304        assert_eq!(playlists[0].id, "pl1");
3305        assert_eq!(playlists[1].id, "pl2");
3306    }
3307
3308    #[test]
3309    fn get_playlist_clips_preserves_array_order_over_created_at() {
3310        // relative_index ascends with array order while the wrapper created_at
3311        // values are non-monotonic. Members must stay in array order: the parser
3312        // never sorts by created_at (or any timestamp).
3313        let body = serde_json::json!({
3314            "num_total_results": 3,
3315            "playlist_clips": [
3316                {"clip": {
3317                    "id": "a", "title": "A", "status": "complete",
3318                    "metadata": {"duration": 60.0, "type": "gen"}
3319                }, "relative_index": 1.0, "created_at": "2026-06-08T00:00:00.000Z"},
3320                {"clip": {
3321                    "id": "b", "title": "B", "status": "complete",
3322                    "metadata": {"duration": 30.0, "type": "gen"}
3323                }, "relative_index": 2.0, "created_at": "2026-01-11T00:00:00.000Z"},
3324                {"clip": {
3325                    "id": "c", "title": "C", "status": "complete",
3326                    "metadata": {"duration": 45.0, "type": "gen"}
3327                }, "relative_index": 3.0, "created_at": "2026-05-15T00:00:00.000Z"}
3328            ]
3329        })
3330        .to_string();
3331        let mut rules = auth_rules();
3332        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
3333        let http = MockHttp::new(rules);
3334        let client = authed_client(&http);
3335
3336        let (clips, complete) =
3337            pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
3338        assert_eq!(
3339            clips.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(),
3340            ["a", "b", "c"],
3341            "array order is preserved despite non-monotonic created_at"
3342        );
3343        assert!(complete, "three intact members equal the declared total");
3344    }
3345
3346    /// A stems page body: each stem is a full clip object whose title carries
3347    /// the label in a trailing parenthetical, as the live endpoint returns.
3348    fn stem_page(stems: &[(&str, &str, &str)]) -> String {
3349        let entries: Vec<Value> = stems
3350            .iter()
3351            .map(|(id, label, url)| {
3352                serde_json::json!({
3353                    "id": id,
3354                    "title": format!("My Song ({label})"),
3355                    "status": "complete",
3356                    "audio_url": url,
3357                })
3358            })
3359            .collect();
3360        serde_json::json!({ "stems": entries }).to_string()
3361    }
3362
3363    /// The page-count body for `GET /api/clip/{id}/stems/pages`.
3364    fn stem_pages(pages: u32) -> String {
3365        serde_json::json!({ "pages": pages }).to_string()
3366    }
3367
3368    #[test]
3369    fn list_stems_drains_all_declared_pages_and_is_authoritative() {
3370        // Two 0-indexed pages, both drained: the stems concatenate in order and
3371        // the listing is authoritative (it declared its pages and held stems).
3372        let http = ScriptedHttp::new()
3373            .with_auth()
3374            .route("stems/pages", Reply::json(&stem_pages(2)))
3375            .route(
3376                "stems?page=0",
3377                Reply::json(&stem_page(&[
3378                    ("s1", "Vocals", "https://cdn1.suno.ai/s1.mp3"),
3379                    ("s2", "Drums", "https://cdn1.suno.ai/s2.mp3"),
3380                ])),
3381            )
3382            .route(
3383                "stems?page=1",
3384                Reply::json(&stem_page(&[("s3", "Bass", "https://cdn1.suno.ai/s3.mp3")])),
3385            );
3386        let client = scripted_client(&http, RecordingClock::new());
3387
3388        let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3389        assert_eq!(stems.len(), 3);
3390        assert_eq!(stems[0].id, "s1");
3391        assert_eq!(stems[0].label, "Vocals");
3392        assert_eq!(stems[0].url, "https://cdn1.suno.ai/s1.mp3");
3393        assert_eq!(stems[2].label, "Bass");
3394        assert!(
3395            complete,
3396            "a fully drained listing that returned stems is authoritative"
3397        );
3398    }
3399
3400    #[test]
3401    fn list_stems_zero_pages_is_indeterminate_never_empty() {
3402        // A clip with no stems answers `{"pages": 0}`. That must NOT be read as an
3403        // authoritative empty set, or it could delete local stems.
3404        let http = ScriptedHttp::new()
3405            .with_auth()
3406            .route("stems/pages", Reply::json(&stem_pages(0)));
3407        let client = scripted_client(&http, RecordingClock::new());
3408
3409        let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3410        assert!(stems.is_empty());
3411        assert!(
3412            !complete,
3413            "an empty listing is indeterminate, so existing stems are kept"
3414        );
3415    }
3416
3417    #[test]
3418    fn list_stems_missing_page_count_is_indeterminate() {
3419        // A `400`/`404` on the page-count endpoint (Suno's "no stems" answer) is
3420        // indeterminate, never an authoritative empty set.
3421        for status in [400u16, 404] {
3422            let http = ScriptedHttp::new()
3423                .with_auth()
3424                .route("stems/pages", Reply::status(status));
3425            let client = scripted_client(&http, RecordingClock::new());
3426            let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3427            assert!(stems.is_empty(), "status {status}");
3428            assert!(!complete, "status {status} is indeterminate, not empty");
3429        }
3430    }
3431
3432    #[test]
3433    fn stem_page_count_5xx_with_invalid_page_body_is_not_no_stems() {
3434        // A `5xx` whose body happens to contain "Invalid page" must NOT be
3435        // classified as "no stems": body-text matching would misclassify it.
3436        // Only a genuine `400` status triggers the no-stems path.
3437        let http = ScriptedHttp::new()
3438            .with_auth()
3439            .route("stems/pages", Reply::with_body(500, "Invalid page"));
3440        let client = scripted_client(&http, RecordingClock::new());
3441
3442        let result = pollster::block_on(client.list_stems(&http, "clip1"));
3443        assert!(
3444            result.is_err(),
3445            "a 5xx is a transient error, never 'no stems'"
3446        );
3447    }
3448
3449    #[test]
3450    fn list_stems_page_error_mid_enumeration_propagates() {
3451        // A transient 5xx on a page mid-drain is indeterminate, not an end: it
3452        // surfaces as an error rather than a (partial) authoritative set, so the
3453        // caller keeps existing stems.
3454        let http = ScriptedHttp::new()
3455            .with_auth()
3456            .route("stems/pages", Reply::json(&stem_pages(2)))
3457            .route(
3458                "stems?page=0",
3459                Reply::json(&stem_page(&[(
3460                    "s1",
3461                    "Vocals",
3462                    "https://cdn1.suno.ai/s1.mp3",
3463                )])),
3464            )
3465            .route("stems?page=1", Reply::status(500));
3466        let client = scripted_client(&http, RecordingClock::new());
3467
3468        let result = pollster::block_on(client.list_stems(&http, "clip1"));
3469        assert!(result.is_err(), "a 5xx page is not a clean drain");
3470    }
3471
3472    #[test]
3473    fn list_stems_over_max_pages_is_truncated_never_authoritative() {
3474        // A clip that declares more pages than the `MAX_PAGES` cap can only be
3475        // drained partially, so even though the fetched pages hold stems the
3476        // listing is TRUNCATED and must not be authoritative: its un-fetched
3477        // stems on pages beyond the cap would otherwise be delete-reconciled.
3478        let http = ScriptedHttp::new()
3479            .with_auth()
3480            .route("stems/pages", Reply::json(&stem_pages(MAX_PAGES + 1)))
3481            .route(
3482                "stems?page=",
3483                Reply::json(&stem_page(&[(
3484                    "s1",
3485                    "Vocals",
3486                    "https://cdn1.suno.ai/s1.mp3",
3487                )])),
3488            );
3489        let client = scripted_client(&http, RecordingClock::new());
3490
3491        let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3492        assert!(!stems.is_empty(), "the fetched pages still yield stems");
3493        assert!(
3494            !complete,
3495            "a listing declaring more than MAX_PAGES is truncated, never authoritative"
3496        );
3497    }
3498
3499    #[test]
3500    fn parse_stems_page_maps_full_clips_and_skips_idless() {
3501        // A stem is a full clip: id, label from the title parenthetical, and the
3502        // public CDN MP3 url.
3503        let page = stem_page(&[("x", "Backing Vocals", "https://cdn1.suno.ai/x.mp3")]);
3504        let stems = parse_stems_page(page.as_bytes());
3505        assert_eq!(stems.len(), 1);
3506        assert_eq!(stems[0].id, "x");
3507        assert_eq!(stems[0].label, "Backing Vocals");
3508        assert_eq!(stems[0].url, "https://cdn1.suno.ai/x.mp3");
3509        // An entry with no id cannot be keyed or WAV-rendered and is dropped.
3510        let no_id = br#"{"stems": [{"title": "Ghost (Vocals)", "audio_url": "https://cdn1.suno.ai/g.mp3"}]}"#;
3511        assert!(parse_stems_page(no_id).is_empty());
3512        // A stem with an id but no audio_url still resolves a deterministic CDN
3513        // url from its id, so it remains downloadable.
3514        let no_url = br#"{"stems": [{"id": "y", "title": "Song (Bass)"}]}"#;
3515        let recovered = parse_stems_page(no_url);
3516        assert_eq!(recovered.len(), 1);
3517        assert_eq!(recovered[0].url, "https://cdn1.suno.ai/y.mp3");
3518        // Malformed JSON never panics; it yields no stems.
3519        assert!(parse_stems_page(b"not json").is_empty());
3520    }
3521
3522    #[test]
3523    fn list_stems_labels_the_inferred_populated_page_from_the_stem_group() {
3524        // The populated `/stems` shape was never captured for this account, so
3525        // it is inferred: each stem is a full clip whose structured
3526        // `metadata.stem_type_group_name` (underscore form) is the label, even
3527        // when the title carries no parenthetical. This pins the normaliser and
3528        // the group-over-title preference against the inferred fixture.
3529        let page = serde_json::json!({
3530            "stems": [{
3531                "id": "stem-bv",
3532                "title": "Track 30",
3533                "status": "complete",
3534                "audio_url": "https://cdn1.suno.ai/stem-bv.mp3",
3535                "metadata": {
3536                    "stem_from_id": "source-074",
3537                    "stem_task": "twelve",
3538                    "stem_type_id": 91.0,
3539                    "stem_type_group_name": "Backing_Vocals"
3540                }
3541            }]
3542        })
3543        .to_string();
3544        let http = ScriptedHttp::new()
3545            .with_auth()
3546            .route("stems/pages", Reply::json(&stem_pages(1)))
3547            .route("stems?page=0", Reply::json(&page));
3548        let client = scripted_client(&http, RecordingClock::new());
3549
3550        let (stems, complete) = pollster::block_on(client.list_stems(&http, "clip1")).unwrap();
3551        assert_eq!(stems.len(), 1);
3552        assert_eq!(stems[0].id, "stem-bv");
3553        assert_eq!(
3554            stems[0].label, "Backing Vocals",
3555            "the underscore group name is normalised, not the empty title parenthetical"
3556        );
3557        assert_eq!(stems[0].url, "https://cdn1.suno.ai/stem-bv.mp3");
3558        assert!(
3559            complete,
3560            "a drained listing that returned a stem is authoritative"
3561        );
3562    }
3563
3564    #[test]
3565    fn stem_label_prefers_the_normalised_group_over_the_title() {
3566        // The structured group name wins and its underscore form is normalised.
3567        let grouped = Clip {
3568            title: "Track 30".to_owned(),
3569            stem_type_group_name: "Backing_Vocals".to_owned(),
3570            ..Default::default()
3571        };
3572        assert_eq!(stem_label(&grouped), "Backing Vocals");
3573        // It still wins over a present title parenthetical (strictly more
3574        // reliable and language-stable than title scraping).
3575        let both = Clip {
3576            title: "My Song (Guitar)".to_owned(),
3577            stem_type_group_name: "Vocals".to_owned(),
3578            ..Default::default()
3579        };
3580        assert_eq!(stem_label(&both), "Vocals");
3581        // No group name: fall back to the title parenthetical.
3582        let titled = Clip {
3583            title: "My Song (Drums)".to_owned(),
3584            ..Default::default()
3585        };
3586        assert_eq!(stem_label(&titled), "Drums");
3587        // Neither present: empty, so the caller falls back to the stem id.
3588        let bare = Clip {
3589            title: "Track 31".to_owned(),
3590            ..Default::default()
3591        };
3592        assert_eq!(stem_label(&bare), "");
3593    }
3594
3595    #[test]
3596    fn parse_stem_page_count_reads_pages_field() {
3597        assert_eq!(parse_stem_page_count(br#"{"pages": 12}"#), 12);
3598        assert_eq!(parse_stem_page_count(br#"{"pages": 0}"#), 0);
3599        // Missing, negative, or non-numeric pages read as 0 (indeterminate).
3600        assert_eq!(parse_stem_page_count(br#"{}"#), 0);
3601        assert_eq!(parse_stem_page_count(br#"{"pages": -1}"#), 0);
3602        assert_eq!(parse_stem_page_count(b"not json"), 0);
3603    }
3604
3605    #[test]
3606    fn stem_label_from_title_extracts_trailing_parenthetical() {
3607        assert_eq!(stem_label_from_title("My Song (Vocals)"), "Vocals");
3608        assert_eq!(
3609            stem_label_from_title("A (b) Song (Backing Vocals)"),
3610            "Backing Vocals"
3611        );
3612        assert_eq!(stem_label_from_title("My Song (Drums) "), "Drums");
3613        // No parenthetical: empty, so the caller falls back to the stem id.
3614        assert_eq!(stem_label_from_title("My Song"), "");
3615        assert_eq!(stem_label_from_title(""), "");
3616    }
3617
3618    #[test]
3619    fn post_allow_list_permits_only_feed_and_wav_render() {
3620        assert!(post_path_allowed(FEED_V3_PATH));
3621        assert!(post_path_allowed("/api/gen/abc123/convert_wav/"));
3622        // No generation endpoint is on the list.
3623        assert!(!post_path_allowed("/api/gen/abc123/stem_task"));
3624        assert!(!post_path_allowed("/api/gen/abc123/separate"));
3625        // Path traversal or extra segments can't smuggle a match.
3626        assert!(!post_path_allowed("/api/gen/a/../evil/convert_wav/"));
3627        assert!(!post_path_allowed("/api/gen/a/b/convert_wav/"));
3628        // The stems endpoints are GET-only and never on the POST allow-list.
3629        assert!(!post_path_allowed("/api/clip/x/stems/pages"));
3630        assert!(!post_path_allowed("/api/clip/x/stems?page=0"));
3631    }
3632
3633    #[test]
3634    fn api_request_refuses_a_post_off_the_allow_list() {
3635        // The single POST chokepoint rejects an off-list POST before the wire, so
3636        // a credit-spending endpoint can never be reached by accident.
3637        let http = MockHttp::new(auth_rules());
3638        let client = authed_client(&http);
3639        let err = pollster::block_on(client.api_request(
3640            &http,
3641            Method::Post,
3642            "/api/gen/x/stem_task",
3643            b"{}".to_vec(),
3644        ))
3645        .unwrap_err();
3646        assert!(matches!(err, Error::Refused(_)));
3647    }
3648}