Skip to main content

suno_core/
client.rs

1//! The Suno API client: lists the library behind the [`Http`](crate::Http) port.
2
3use serde_json::Value;
4
5use crate::auth::ClerkAuth;
6use crate::consts::{
7    CLIP_PARENT_PATH, FEED_V2_PATH, IDS_PER_REQUEST, MAX_PAGES, PLAYLIST_ME_PATH, PLAYLIST_PATH,
8    SUNO_API_BASE_URL,
9};
10use crate::error::{Error, Result};
11use crate::http::{Http, HttpRequest, Method};
12use crate::model::Clip;
13
14const EXCLUDED_TASKS: [&str; 2] = ["infill", "fixed_infill"];
15const EXCLUDED_TYPES: [&str; 1] = ["rendered_context_window"];
16
17/// One of the account's own playlists, as listed by `/api/playlist/me`.
18///
19/// Carries only what playlist reconciliation needs: the stable id (the state
20/// key), the display name (drives the `.m3u8` file name and `#PLAYLIST` line),
21/// and the member count for reporting. The ordered members are fetched
22/// separately with [`SunoClient::get_playlist_clips`].
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Playlist {
25    /// The playlist's stable Suno id.
26    pub id: String,
27    /// The playlist's display name.
28    pub name: String,
29    /// The number of clips Suno reports in the playlist.
30    pub num_clips: u64,
31}
32
33/// A client for the Suno library API, owning the account's [`ClerkAuth`].
34pub struct SunoClient {
35    auth: ClerkAuth,
36}
37
38impl SunoClient {
39    /// Create a client from a fresh or already-authenticated [`ClerkAuth`].
40    pub fn new(auth: ClerkAuth) -> Self {
41        Self { auth }
42    }
43
44    /// Borrow the underlying authenticator.
45    pub fn auth(&self) -> &ClerkAuth {
46        &self.auth
47    }
48
49    /// List clips across the whole library, or only liked clips.
50    ///
51    /// Stops early once `limit` clips are collected. Paging is hard-capped at
52    /// [`MAX_PAGES`] so a runaway `has_more` can never loop forever.
53    ///
54    /// Returns the clips paired with a `complete` flag that is `true` only when
55    /// paging ended because the server reported `has_more == false` (the feed
56    /// fully drained). A `limit` stop, or exhausting [`MAX_PAGES`] while
57    /// `has_more` is still set, yields `false` so the caller can refuse to treat
58    /// a truncated listing as authoritative for deletion.
59    pub async fn list_clips(
60        &mut self,
61        http: &impl Http,
62        liked: bool,
63        limit: Option<usize>,
64    ) -> Result<(Vec<Clip>, bool)> {
65        let mut clips = Vec::new();
66        let suffix = if liked { "&is_liked=true" } else { "" };
67        let mut complete = false;
68        for page in 0..MAX_PAGES {
69            let path = format!("/api/feed/v2/?page={page}{suffix}");
70            let body = self.api_get(http, &path).await?;
71            let (page_clips, has_more) = parse_feed(&body)?;
72            clips.extend(page_clips);
73            if !has_more {
74                complete = true;
75                break;
76            }
77            if limit.is_some_and(|n| clips.len() >= n) {
78                break;
79            }
80        }
81        if let Some(n) = limit {
82            clips.truncate(n);
83        }
84        Ok((clips, complete))
85    }
86
87    /// Fetch one clip by ID.
88    ///
89    /// Tries the dedicated `/api/clip/{id}` endpoint first, then falls back to
90    /// scanning the library feed, since that endpoint's exact shape is not yet
91    /// confirmed against the live API.
92    pub async fn get_clip(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
93        if let Some(clip) = self.try_get_clip(http, id).await? {
94            return Ok(clip);
95        }
96        self.find_in_feed(http, id).await
97    }
98
99    /// Ask Suno to render a clip to lossless WAV (server-side, asynchronous).
100    pub async fn request_wav(&mut self, http: &impl Http, id: &str) -> Result<()> {
101        let path = format!("/api/gen/{id}/convert_wav/");
102        self.api_request(http, Method::Post, &path).await?;
103        Ok(())
104    }
105
106    /// Read the rendered WAV URL for a clip, or `None` while it is not ready.
107    pub async fn wav_url(&mut self, http: &impl Http, id: &str) -> Result<Option<String>> {
108        let path = format!("/api/gen/{id}/wav_file/");
109        let body = self.api_get(http, &path).await?;
110        let data: Value = serde_json::from_slice(&body)
111            .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
112        Ok(data
113            .get("wav_file_url")
114            .and_then(Value::as_str)
115            .filter(|url| !url.is_empty())
116            .map(str::to_string))
117    }
118
119    /// Fetch specific clips by id through the feed's `?ids=` filter.
120    ///
121    /// Used by lineage resolution to gap-fill ancestors that are absent from a
122    /// normal listing, including trashed ones. Unlike
123    /// [`list_clips`](Self::list_clips), no `keep_clip` filtering is applied: an
124    /// ancestor may itself be an infill or context-window artefact that the
125    /// lineage walk must still traverse. Clips returned here are ancestors for
126    /// resolution only and must never be treated as download candidates. Ids are
127    /// chunked so a long list cannot build an over-long URL.
128    pub async fn get_clips_by_ids(&mut self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
129        let mut clips = Vec::new();
130        for chunk in ids.chunks(IDS_PER_REQUEST) {
131            if chunk.is_empty() {
132                continue;
133            }
134            let joined = chunk.join(",");
135            let path = format!("{FEED_V2_PATH}?ids={joined}");
136            let body = self.api_get(http, &path).await?;
137            clips.extend(map_all_clips(&body)?);
138        }
139        Ok(clips)
140    }
141
142    /// Fetch a clip's immediate parent via the dedicated parent endpoint.
143    ///
144    /// Returns the parent clip, or `None` when the clip is a root (no parent) or
145    /// the endpoint yields no clip. Lineage resolution uses this as a fallback
146    /// when a missing ancestor cannot be retrieved by id. Only a `404` (the clip
147    /// has no parent) maps to `None`; any other failure, including a transient
148    /// `5xx`, propagates as an error rather than being mistaken for a root.
149    pub async fn get_clip_parent(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
150        let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
151        match self.api_get(http, &path).await {
152            Ok(body) => Ok(parse_clip(&body)),
153            Err(Error::NotFound(_)) => Ok(None),
154            Err(err) => Err(err),
155        }
156    }
157
158    /// List the account's own playlists, paging `/api/playlist/me`.
159    ///
160    /// Trashed and share-list playlists are excluded by query, so the result is
161    /// the account's authoritative own set. Paging stops on the first empty page
162    /// and is hard-capped at [`MAX_PAGES`] so a server that ignores the page
163    /// parameter cannot loop forever. Only entries with a non-empty id are kept.
164    ///
165    /// A hard failure propagates as an error; the caller treats that as "the
166    /// playlist listing did not fully enumerate" and refuses every playlist
167    /// deletion this run, so a dropped fetch can never remove a `.m3u8`.
168    pub async fn get_playlists(&mut self, http: &impl Http) -> Result<Vec<Playlist>> {
169        let mut playlists = Vec::new();
170        for page in 1..=MAX_PAGES {
171            let path =
172                format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
173            let body = self.api_get(http, &path).await?;
174            let page_playlists = parse_playlists(&body)?;
175            if page_playlists.is_empty() {
176                break;
177            }
178            playlists.extend(page_playlists);
179        }
180        Ok(playlists)
181    }
182
183    /// Fetch one playlist's clips in Suno order via `/api/playlist/{id}/`.
184    ///
185    /// The response's `playlist_clips[]` is already ordered and trashed members
186    /// are excluded by Suno, so the order is preserved exactly and no `keep_clip`
187    /// filtering is applied — a playlist may legitimately contain any clip. Each
188    /// entry's `clip` object is mapped (falling back to the entry itself), and
189    /// only clips with a non-empty id are kept.
190    pub async fn get_playlist_clips(&mut self, http: &impl Http, id: &str) -> Result<Vec<Clip>> {
191        let path = format!("{PLAYLIST_PATH}{id}/");
192        let body = self.api_get(http, &path).await?;
193        parse_playlist_clips(&body)
194    }
195
196    /// Try the dedicated clip endpoint, returning `None` when it is missing or
197    /// returns a body that does not yield the requested clip.
198    async fn try_get_clip(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
199        let path = format!("/api/clip/{id}");
200        match self.api_get(http, &path).await {
201            Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
202            Err(Error::NotFound(_)) => Ok(None),
203            Err(err) => Err(err),
204        }
205    }
206
207    /// Locate a clip by scanning the library feed.
208    async fn find_in_feed(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
209        let (clips, _complete) = self.list_clips(http, false, None).await?;
210        clips
211            .into_iter()
212            .find(|clip| clip.id == id)
213            .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
214    }
215
216    /// Perform an authenticated GET, refreshing the JWT once on a 401/403.
217    async fn api_get(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
218        self.api_request(http, Method::Get, path).await
219    }
220
221    /// Perform an authenticated request, refreshing the JWT once on a 401/403.
222    async fn api_request(
223        &mut self,
224        http: &impl Http,
225        method: Method,
226        path: &str,
227    ) -> Result<Vec<u8>> {
228        let url = format!("{SUNO_API_BASE_URL}{path}");
229        for attempt in 0..2 {
230            let jwt = self.auth.ensure_jwt(http).await?;
231            let request = HttpRequest {
232                method,
233                url: url.clone(),
234                headers: vec![("Authorization".to_string(), format!("Bearer {jwt}"))],
235            };
236            let response = http
237                .send(request)
238                .await
239                .map_err(|err| Error::Connection(err.to_string()))?;
240            match response.status {
241                200..=299 => return Ok(response.body),
242                401 | 403 if attempt == 0 => self.auth.invalidate_jwt(),
243                401 | 403 => {
244                    return Err(Error::Auth(format!(
245                        "Suno API auth failed with status {}",
246                        response.status
247                    )));
248                }
249                429 => return Err(Error::RateLimited),
250                404 => {
251                    return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
252                }
253                status => {
254                    let preview: String = String::from_utf8_lossy(&response.body)
255                        .chars()
256                        .take(200)
257                        .collect();
258                    return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
259                }
260            }
261        }
262        Err(Error::Api("Suno API request failed after retries".into()))
263    }
264}
265
266/// Parse a single-clip response body, accepting either a bare clip object or a
267/// `{"clip": {...}}` wrapper. Returns `None` when no clip id is present.
268fn parse_clip(body: &[u8]) -> Option<Clip> {
269    let data: Value = serde_json::from_slice(body).ok()?;
270    let raw = data
271        .get("clip")
272        .filter(|value| value.is_object())
273        .unwrap_or(&data);
274    let has_id = raw
275        .get("id")
276        .and_then(Value::as_str)
277        .is_some_and(|id| !id.is_empty());
278    has_id.then(|| Clip::from_json(raw))
279}
280
281/// Parse a feed page body into the kept clips and the `has_more` flag.
282fn parse_feed(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
283    let data: Value = serde_json::from_slice(body)
284        .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
285    let Some(object) = data.as_object() else {
286        return Ok((Vec::new(), false));
287    };
288    let clips = object
289        .get("clips")
290        .and_then(Value::as_array)
291        .map(|raw| {
292            raw.iter()
293                .filter(|clip| keep_clip(clip))
294                .map(Clip::from_json)
295                .collect()
296        })
297        .unwrap_or_default();
298    let has_more = object
299        .get("has_more")
300        .and_then(Value::as_bool)
301        .unwrap_or(false);
302    Ok((clips, has_more))
303}
304
305/// Map every clip in a feed-shaped body, skipping the `keep_clip` filter.
306///
307/// Accepts either a `{"clips": [...]}` wrapper or a bare array, dropping only
308/// elements that carry no id. Used for id-filtered gap-fill fetches, which must
309/// preserve trashed and artefact clips so the lineage walk can traverse them.
310fn map_all_clips(body: &[u8]) -> Result<Vec<Clip>> {
311    let data: Value = serde_json::from_slice(body)
312        .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
313    let items: &[Value] = match &data {
314        Value::Array(items) => items.as_slice(),
315        Value::Object(_) => data
316            .get("clips")
317            .and_then(Value::as_array)
318            .map_or(&[][..], |arr| arr.as_slice()),
319        _ => &[],
320    };
321    Ok(items
322        .iter()
323        .map(Clip::from_json)
324        .filter(|clip| !clip.id.is_empty())
325        .collect())
326}
327
328/// Parse a `/api/playlist/me` page into playlists, dropping entries with no id.
329fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
330    let data: Value = serde_json::from_slice(body)
331        .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
332    Ok(data
333        .get("playlists")
334        .and_then(Value::as_array)
335        .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
336        .unwrap_or_default())
337}
338
339/// Map one raw `/api/playlist/me` entry, or `None` when it carries no id.
340///
341/// `num_total_results` is the playlist's member count; a missing name defaults
342/// to `Untitled` (matching the clip mapping) so the file name is never empty.
343fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
344    let id = raw
345        .get("id")
346        .and_then(Value::as_str)
347        .filter(|id| !id.is_empty())?
348        .to_string();
349    let name = match raw.get("name") {
350        Some(Value::String(name)) if !name.is_empty() => name.clone(),
351        _ => "Untitled".to_string(),
352    };
353    let num_clips = raw
354        .get("num_total_results")
355        .and_then(Value::as_u64)
356        .unwrap_or(0);
357    Some(Playlist {
358        id,
359        name,
360        num_clips,
361    })
362}
363
364/// Parse a `/api/playlist/{id}/` body into its ordered member clips.
365///
366/// Each `playlist_clips[]` entry wraps the clip under `clip`; the wrapper is
367/// unwrapped (falling back to the entry itself), order is preserved exactly, and
368/// only clips with a non-empty id survive. No `keep_clip` filter is applied: a
369/// playlist may hold any clip, and members absent from the local library are
370/// reconciled as comment lines by the caller, not dropped here.
371fn parse_playlist_clips(body: &[u8]) -> Result<Vec<Clip>> {
372    let data: Value = serde_json::from_slice(body)
373        .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
374    Ok(data
375        .get("playlist_clips")
376        .and_then(Value::as_array)
377        .map(|raw| {
378            raw.iter()
379                .map(|entry| {
380                    let clip = entry
381                        .get("clip")
382                        .filter(|value| value.is_object())
383                        .unwrap_or(entry);
384                    Clip::from_json(clip)
385                })
386                .filter(|clip| !clip.id.is_empty())
387                .collect()
388        })
389        .unwrap_or_default())
390}
391
392/// Keep only finished clips that are not infills or context-window artefacts.
393fn keep_clip(raw: &Value) -> bool {
394    if raw.get("status").and_then(Value::as_str) != Some("complete") {
395        return false;
396    }
397    let metadata = raw.get("metadata");
398    let clip_type = metadata.and_then(|m| m.get("type")).and_then(Value::as_str);
399    if clip_type.is_some_and(|t| EXCLUDED_TYPES.contains(&t)) {
400        return false;
401    }
402    let task = metadata.and_then(|m| m.get("task")).and_then(Value::as_str);
403    !task.is_some_and(|t| EXCLUDED_TASKS.contains(&t))
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use crate::testutil::{MockHttp, Rule};
410
411    fn feed_body() -> String {
412        serde_json::json!({
413            "has_more": false,
414            "clips": [
415                {
416                    "id": "a", "title": "Song A", "status": "complete",
417                    "audio_url": "https://cdn1.suno.ai/a.mp3",
418                    "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
419                },
420                {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
421                {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
422                {
423                    "id": "d", "title": "Context", "status": "complete",
424                    "metadata": {"type": "rendered_context_window"}
425                }
426            ]
427        })
428        .to_string()
429    }
430
431    #[test]
432    fn parse_feed_filters_and_maps() {
433        let (clips, has_more) = parse_feed(feed_body().as_bytes()).unwrap();
434        assert!(!has_more);
435        assert_eq!(clips.len(), 1);
436        assert_eq!(clips[0].id, "a");
437        assert_eq!(clips[0].tags, "rock");
438        assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
439    }
440
441    #[test]
442    fn audiopipe_url_is_rewritten_to_cdn() {
443        let raw =
444            serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
445        assert_eq!(
446            Clip::from_json(&raw).audio_url,
447            "https://cdn1.suno.ai/x.mp3"
448        );
449    }
450
451    #[test]
452    fn list_clips_authenticates_then_reads_the_feed() {
453        let client_body = serde_json::json!({
454            "response": {
455                "last_active_session_id": "s",
456                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
457            }
458        })
459        .to_string();
460        let http = MockHttp::new(vec![
461            Rule::new(
462                "/v1/client/sessions/",
463                200,
464                r#"{"jwt": "a.b.c"}"#.to_string(),
465            ),
466            Rule::new("/v1/client", 200, client_body),
467            Rule::new("/api/feed/v2", 200, feed_body()),
468        ]);
469
470        let mut auth = ClerkAuth::new("eyJtoken");
471        pollster::block_on(auth.authenticate(&http)).unwrap();
472        let mut client = SunoClient::new(auth);
473        let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
474        assert_eq!(clips.len(), 1);
475        assert_eq!(clips[0].id, "a");
476        assert!(complete);
477    }
478
479    #[test]
480    fn list_clips_reports_incomplete_when_paging_is_capped() {
481        let mut rules = auth_rules();
482        rules.push(Rule::new(
483            "/api/feed/v2",
484            200,
485            serde_json::json!({
486                "has_more": true,
487                "clips": [{
488                    "id": "a", "title": "Song A", "status": "complete",
489                    "audio_url": "https://cdn1.suno.ai/a.mp3",
490                    "metadata": {"type": "gen"}
491                }]
492            })
493            .to_string(),
494        ));
495        let http = MockHttp::new(rules);
496        let mut client = authed_client(&http);
497
498        let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
499        assert!(!complete);
500    }
501
502    fn auth_rules() -> Vec<Rule> {
503        let client_body = serde_json::json!({
504            "response": {
505                "last_active_session_id": "s",
506                "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
507            }
508        })
509        .to_string();
510        vec![
511            Rule::new(
512                "/v1/client/sessions/",
513                200,
514                r#"{"jwt": "a.b.c"}"#.to_string(),
515            ),
516            Rule::new("/v1/client", 200, client_body),
517        ]
518    }
519
520    fn authed_client(http: &MockHttp) -> SunoClient {
521        let mut auth = ClerkAuth::new("eyJtoken");
522        pollster::block_on(auth.authenticate(http)).unwrap();
523        SunoClient::new(auth)
524    }
525
526    #[test]
527    fn parse_clip_accepts_bare_and_wrapped_shapes() {
528        let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
529        assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
530
531        let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
532        assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
533
534        let missing = serde_json::json!({"detail": "not found"}).to_string();
535        assert!(parse_clip(missing.as_bytes()).is_none());
536    }
537
538    #[test]
539    fn get_clip_uses_the_dedicated_endpoint() {
540        let clip_body = serde_json::json!({
541            "id": "z", "title": "Zed", "status": "complete",
542            "audio_url": "https://cdn1.suno.ai/z.mp3",
543            "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
544        })
545        .to_string();
546        let mut rules = auth_rules();
547        rules.push(Rule::new("/api/clip/", 200, clip_body));
548        let http = MockHttp::new(rules);
549        let mut client = authed_client(&http);
550
551        let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
552        assert_eq!(clip.id, "z");
553        assert_eq!(clip.title, "Zed");
554        assert_eq!(clip.tags, "jazz");
555    }
556
557    #[test]
558    fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
559        let mut rules = auth_rules();
560        rules.push(Rule::new(
561            "/api/clip/",
562            404,
563            r#"{"detail": "not found"}"#.to_string(),
564        ));
565        rules.push(Rule::new("/api/feed/v2", 200, feed_body()));
566        let http = MockHttp::new(rules);
567        let mut client = authed_client(&http);
568
569        let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
570        assert_eq!(clip.id, "a");
571        assert_eq!(clip.tags, "rock");
572    }
573
574    #[test]
575    fn request_wav_accepts_a_2xx_status() {
576        let mut rules = auth_rules();
577        rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
578        let http = MockHttp::new(rules);
579        let mut client = authed_client(&http);
580
581        assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
582    }
583
584    #[test]
585    fn wav_url_reads_the_ready_url() {
586        let mut rules = auth_rules();
587        rules.push(Rule::new(
588            "/wav_file/",
589            200,
590            r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
591        ));
592        let http = MockHttp::new(rules);
593        let mut client = authed_client(&http);
594
595        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
596        assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
597    }
598
599    #[test]
600    fn wav_url_is_none_until_the_render_is_ready() {
601        let mut rules = auth_rules();
602        rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
603        let http = MockHttp::new(rules);
604        let mut client = authed_client(&http);
605
606        let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
607        assert_eq!(url, None);
608    }
609
610    #[test]
611    fn get_clips_by_ids_uses_the_ids_filter_and_keeps_all_clips() {
612        // The `?ids=` gap-fill path must not apply the listing's `keep_clip`
613        // filter: an infill ancestor and an upload root both survive.
614        let feed = serde_json::json!({
615            "clips": [
616                {
617                    "id": "p1", "title": "Infill Ancestor", "status": "complete",
618                    "metadata": {"type": "gen", "task": "infill"}
619                },
620                {
621                    "id": "p2", "title": "Uploaded Root", "status": "complete",
622                    "metadata": {"type": "upload"}
623                }
624            ]
625        })
626        .to_string();
627        let mut rules = auth_rules();
628        // The exact substring also asserts the ids are comma-joined into the URL.
629        rules.push(Rule::new("/api/feed/v2/?ids=p1,p2", 200, feed));
630        let http = MockHttp::new(rules);
631        let mut client = authed_client(&http);
632
633        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"])).unwrap();
634        assert_eq!(
635            clips.len(),
636            2,
637            "infill and upload ancestors must not be filtered"
638        );
639        assert_eq!(clips[0].id, "p1");
640        assert_eq!(clips[1].id, "p2");
641    }
642
643    #[test]
644    fn get_clips_by_ids_accepts_a_bare_array_body() {
645        let body = serde_json::json!([
646            {"id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}}
647        ])
648        .to_string();
649        let mut rules = auth_rules();
650        rules.push(Rule::new("/api/feed/v2/?ids=only", 200, body));
651        let http = MockHttp::new(rules);
652        let mut client = authed_client(&http);
653
654        let clips = pollster::block_on(client.get_clips_by_ids(&http, &["only"])).unwrap();
655        assert_eq!(clips.len(), 1);
656        assert_eq!(clips[0].id, "only");
657    }
658
659    #[test]
660    fn get_clip_parent_reads_the_parent_clip() {
661        let parent = serde_json::json!({
662            "id": "par", "title": "Ancestor", "status": "complete",
663            "metadata": {"type": "gen"}
664        })
665        .to_string();
666        let mut rules = auth_rules();
667        rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
668        let http = MockHttp::new(rules);
669        let mut client = authed_client(&http);
670
671        let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
672        assert_eq!(clip.unwrap().id, "par");
673    }
674
675    #[test]
676    fn get_clip_parent_is_none_for_a_root() {
677        let mut rules = auth_rules();
678        rules.push(Rule::new(
679            "/api/clips/parent",
680            404,
681            r#"{"detail": "no parent"}"#.to_string(),
682        ));
683        let http = MockHttp::new(rules);
684        let mut client = authed_client(&http);
685
686        let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
687        assert!(clip.is_none());
688    }
689
690    #[test]
691    fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
692        // A transient 5xx must never be mistaken for "this clip is a root":
693        // folding it into Ok(None) would fabricate a wrong external root and let
694        // a blip rewrite lineage (HARDENING H3). Only a real 404 means no parent.
695        for status in [500u16, 503] {
696            let mut rules = auth_rules();
697            rules.push(Rule::new(
698                "/api/clips/parent",
699                status,
700                r#"{"detail": "server error"}"#.to_string(),
701            ));
702            let http = MockHttp::new(rules);
703            let mut client = authed_client(&http);
704
705            let result = pollster::block_on(client.get_clip_parent(&http, "child"));
706            assert!(
707                matches!(result, Err(Error::Api(_))),
708                "status {status} must propagate as an error, not Ok(None)"
709            );
710        }
711    }
712
713    #[test]
714    fn get_playlists_maps_entries_and_skips_missing_ids() {
715        let page1 = serde_json::json!({
716            "playlists": [
717                {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
718                {"id": "", "name": "No Id", "num_total_results": 3},
719                {"name": "Also No Id"}
720            ]
721        })
722        .to_string();
723        let mut rules = auth_rules();
724        // Page 1 returns entries; page 2 is empty, ending pagination.
725        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
726        rules.push(Rule::new(
727            "/api/playlist/me?page=2",
728            200,
729            r#"{"playlists": []}"#.to_string(),
730        ));
731        let http = MockHttp::new(rules);
732        let mut client = authed_client(&http);
733
734        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
735        assert_eq!(playlists.len(), 1, "entries without an id are dropped");
736        assert_eq!(
737            playlists[0],
738            Playlist {
739                id: "pl1".to_owned(),
740                name: "Road Trip".to_owned(),
741                num_clips: 12,
742            }
743        );
744    }
745
746    #[test]
747    fn get_playlists_defaults_a_missing_name_to_untitled() {
748        let page1 = serde_json::json!({
749            "playlists": [{"id": "pl9", "num_total_results": 1}]
750        })
751        .to_string();
752        let mut rules = auth_rules();
753        rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
754        rules.push(Rule::new(
755            "/api/playlist/me?page=2",
756            200,
757            r#"{"playlists": []}"#.to_string(),
758        ));
759        let http = MockHttp::new(rules);
760        let mut client = authed_client(&http);
761
762        let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
763        assert_eq!(playlists[0].name, "Untitled");
764    }
765
766    #[test]
767    fn get_playlist_clips_preserves_order_and_unwraps_clip() {
768        // Members arrive wrapped under `clip`, in playlist order, already
769        // non-trashed. Order is preserved and no keep_clip filter is applied.
770        let body = serde_json::json!({
771            "playlist_clips": [
772                {"clip": {
773                    "id": "second", "title": "Second", "status": "complete",
774                    "metadata": {"duration": 60.0, "type": "gen"}
775                }},
776                {"clip": {
777                    "id": "first", "title": "First", "status": "complete",
778                    "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
779                }}
780            ]
781        })
782        .to_string();
783        let mut rules = auth_rules();
784        rules.push(Rule::new("/api/playlist/pl1/", 200, body));
785        let http = MockHttp::new(rules);
786        let mut client = authed_client(&http);
787
788        let clips = pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
789        assert_eq!(clips.len(), 2, "an infill member is not filtered out");
790        assert_eq!(clips[0].id, "second");
791        assert_eq!(clips[1].id, "first");
792    }
793
794    #[test]
795    fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
796        let mut rules = auth_rules();
797        rules.push(Rule::new(
798            "/api/playlist/empty/",
799            200,
800            r#"{"playlist_clips": []}"#.to_string(),
801        ));
802        let http = MockHttp::new(rules);
803        let mut client = authed_client(&http);
804
805        let clips = pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
806        assert!(clips.is_empty());
807    }
808}