Skip to main content

suno_core/
client.rs

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