1use serde_json::Value;
4
5use crate::auth::ClerkAuth;
6use crate::backoff::{backoff_delay, retry_after};
7use crate::clock::Clock;
8use crate::consts::{
9 API_MAX_RETRIES, CLIP_PARENT_PATH, FEED_PAGE_DELAY, FEED_PAGE_SIZE, FEED_V2_PATH,
10 IDS_PER_REQUEST, MAX_PAGES, PLAYLIST_ME_PATH, PLAYLIST_PATH, SUNO_API_BASE_URL,
11};
12use crate::error::{Error, Result};
13use crate::http::{Http, HttpRequest, Method};
14use crate::model::Clip;
15
16const EXCLUDED_TASKS: [&str; 2] = ["infill", "fixed_infill"];
17const EXCLUDED_TYPES: [&str; 1] = ["rendered_context_window"];
18
19#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct Playlist {
27 pub id: String,
29 pub name: String,
31 pub num_clips: u64,
33}
34
35pub struct SunoClient<C> {
42 auth: ClerkAuth,
43 clock: C,
44}
45
46impl<C: Clock> SunoClient<C> {
47 pub fn new(auth: ClerkAuth, clock: C) -> Self {
49 Self { auth, clock }
50 }
51
52 pub fn auth(&self) -> &ClerkAuth {
54 &self.auth
55 }
56
57 pub async fn list_clips(
68 &mut self,
69 http: &impl Http,
70 liked: bool,
71 limit: Option<usize>,
72 ) -> Result<(Vec<Clip>, bool)> {
73 let mut clips = Vec::new();
74 let suffix = if liked { "&is_liked=true" } else { "" };
75 let mut complete = false;
76 for page in 0..MAX_PAGES {
77 if page > 0 {
78 self.clock.sleep(FEED_PAGE_DELAY).await;
79 }
80 let path = format!("{FEED_V2_PATH}?page={page}&page_size={FEED_PAGE_SIZE}{suffix}");
81 let body = self.api_get_retrying(http, &path).await?;
82 let (page_clips, has_more) = parse_feed(&body)?;
83 clips.extend(page_clips);
84 if !has_more {
85 complete = true;
86 break;
87 }
88 if limit.is_some_and(|n| clips.len() >= n) {
89 break;
90 }
91 }
92 if let Some(n) = limit {
93 clips.truncate(n);
94 }
95 Ok((clips, complete))
96 }
97
98 pub async fn get_clip(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
104 if let Some(clip) = self.try_get_clip(http, id).await? {
105 return Ok(clip);
106 }
107 self.find_in_feed(http, id).await
108 }
109
110 pub async fn request_wav(&mut self, http: &impl Http, id: &str) -> Result<()> {
112 let path = format!("/api/gen/{id}/convert_wav/");
113 self.api_request(http, Method::Post, &path).await?;
114 Ok(())
115 }
116
117 pub async fn wav_url(&mut self, http: &impl Http, id: &str) -> Result<Option<String>> {
119 let path = format!("/api/gen/{id}/wav_file/");
120 let body = self.api_get(http, &path).await?;
121 let data: Value = serde_json::from_slice(&body)
122 .map_err(|err| Error::Api(format!("invalid wav_file JSON: {err}")))?;
123 Ok(data
124 .get("wav_file_url")
125 .and_then(Value::as_str)
126 .filter(|url| !url.is_empty())
127 .map(str::to_string))
128 }
129
130 pub async fn get_clips_by_ids(&mut self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
140 let mut clips = Vec::new();
141 for chunk in ids.chunks(IDS_PER_REQUEST) {
142 if chunk.is_empty() {
143 continue;
144 }
145 let joined = chunk.join(",");
146 let path = format!("{FEED_V2_PATH}?ids={joined}");
147 let body = self.api_get_retrying(http, &path).await?;
148 clips.extend(map_all_clips(&body)?);
149 }
150 Ok(clips)
151 }
152
153 pub async fn get_clip_parent(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
161 let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
162 match self.api_get_retrying(http, &path).await {
163 Ok(body) => Ok(parse_clip(&body)),
164 Err(Error::NotFound(_)) => Ok(None),
165 Err(err) => Err(err),
166 }
167 }
168
169 pub async fn get_playlists(&mut self, http: &impl Http) -> Result<Vec<Playlist>> {
180 let mut playlists = Vec::new();
181 for page in 1..=MAX_PAGES {
182 if page > 1 {
183 self.clock.sleep(FEED_PAGE_DELAY).await;
184 }
185 let path =
186 format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
187 let body = self.api_get_retrying(http, &path).await?;
188 let page_playlists = parse_playlists(&body)?;
189 if page_playlists.is_empty() {
190 break;
191 }
192 playlists.extend(page_playlists);
193 }
194 Ok(playlists)
195 }
196
197 pub async fn get_playlist_clips(&mut self, http: &impl Http, id: &str) -> Result<Vec<Clip>> {
205 let path = format!("{PLAYLIST_PATH}{id}/");
206 let body = self.api_get_retrying(http, &path).await?;
207 parse_playlist_clips(&body)
208 }
209
210 async fn try_get_clip(&mut self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
213 let path = format!("/api/clip/{id}");
214 match self.api_get_retrying(http, &path).await {
215 Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
216 Err(Error::NotFound(_)) => Ok(None),
217 Err(err) => Err(err),
218 }
219 }
220
221 async fn find_in_feed(&mut self, http: &impl Http, id: &str) -> Result<Clip> {
223 let (clips, _complete) = self.list_clips(http, false, None).await?;
224 clips
225 .into_iter()
226 .find(|clip| clip.id == id)
227 .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
228 }
229
230 async fn api_get(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
232 self.api_request(http, Method::Get, path).await
233 }
234
235 async fn api_get_retrying(&mut self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
245 let mut retries = 0;
246 loop {
247 match self.api_get(http, path).await {
248 Ok(body) => return Ok(body),
249 Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
250 self.clock.sleep(backoff_delay(retries, retry_after)).await;
251 retries += 1;
252 }
253 Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
254 self.clock.sleep(backoff_delay(retries, None)).await;
255 retries += 1;
256 }
257 Err(err) => return Err(err),
258 }
259 }
260 }
261
262 async fn api_request(
264 &mut self,
265 http: &impl Http,
266 method: Method,
267 path: &str,
268 ) -> Result<Vec<u8>> {
269 let url = format!("{SUNO_API_BASE_URL}{path}");
270 let mut auth_refreshed = false;
271 loop {
272 let jwt = self.auth.ensure_jwt(http).await?;
273 let request = HttpRequest {
274 method,
275 url: url.clone(),
276 headers: vec![("Authorization".to_string(), format!("Bearer {jwt}"))],
277 };
278 let response = http
279 .send(request)
280 .await
281 .map_err(|err| Error::Connection(err.to_string()))?;
282 match response.status {
283 200..=299 => return Ok(response.body),
284 401 | 403 if !auth_refreshed => {
285 self.auth.invalidate_jwt();
286 auth_refreshed = true;
287 }
288 401 | 403 => {
289 return Err(Error::Auth(format!(
290 "Suno API auth failed with status {}",
291 response.status
292 )));
293 }
294 429 => {
295 return Err(Error::RateLimited {
296 retry_after: retry_after(&response),
297 });
298 }
299 404 => {
300 return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
301 }
302 status => {
303 let preview: String = String::from_utf8_lossy(&response.body)
304 .chars()
305 .take(200)
306 .collect();
307 return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
308 }
309 }
310 }
311 }
312}
313
314fn parse_clip(body: &[u8]) -> Option<Clip> {
317 let data: Value = serde_json::from_slice(body).ok()?;
318 let raw = data
319 .get("clip")
320 .filter(|value| value.is_object())
321 .unwrap_or(&data);
322 let has_id = raw
323 .get("id")
324 .and_then(Value::as_str)
325 .is_some_and(|id| !id.is_empty());
326 has_id.then(|| Clip::from_json(raw))
327}
328
329fn parse_feed(body: &[u8]) -> Result<(Vec<Clip>, bool)> {
331 let data: Value = serde_json::from_slice(body)
332 .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
333 let Some(object) = data.as_object() else {
334 return Ok((Vec::new(), false));
335 };
336 let clips = object
337 .get("clips")
338 .and_then(Value::as_array)
339 .map(|raw| {
340 raw.iter()
341 .filter(|clip| keep_clip(clip))
342 .map(Clip::from_json)
343 .collect()
344 })
345 .unwrap_or_default();
346 let has_more = object
347 .get("has_more")
348 .and_then(Value::as_bool)
349 .unwrap_or(false);
350 Ok((clips, has_more))
351}
352
353fn map_all_clips(body: &[u8]) -> Result<Vec<Clip>> {
359 let data: Value = serde_json::from_slice(body)
360 .map_err(|err| Error::Api(format!("invalid feed JSON: {err}")))?;
361 let items: &[Value] = match &data {
362 Value::Array(items) => items.as_slice(),
363 Value::Object(_) => data
364 .get("clips")
365 .and_then(Value::as_array)
366 .map_or(&[][..], |arr| arr.as_slice()),
367 _ => &[],
368 };
369 Ok(items
370 .iter()
371 .map(Clip::from_json)
372 .filter(|clip| !clip.id.is_empty())
373 .collect())
374}
375
376fn parse_playlists(body: &[u8]) -> Result<Vec<Playlist>> {
378 let data: Value = serde_json::from_slice(body)
379 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
380 Ok(data
381 .get("playlists")
382 .and_then(Value::as_array)
383 .map(|raw| raw.iter().filter_map(parse_playlist_item).collect())
384 .unwrap_or_default())
385}
386
387fn parse_playlist_item(raw: &Value) -> Option<Playlist> {
392 let id = raw
393 .get("id")
394 .and_then(Value::as_str)
395 .filter(|id| !id.is_empty())?
396 .to_string();
397 let name = match raw.get("name") {
398 Some(Value::String(name)) if !name.is_empty() => name.clone(),
399 _ => "Untitled".to_string(),
400 };
401 let num_clips = raw
402 .get("num_total_results")
403 .and_then(Value::as_u64)
404 .unwrap_or(0);
405 Some(Playlist {
406 id,
407 name,
408 num_clips,
409 })
410}
411
412fn parse_playlist_clips(body: &[u8]) -> Result<Vec<Clip>> {
420 let data: Value = serde_json::from_slice(body)
421 .map_err(|err| Error::Api(format!("invalid playlist JSON: {err}")))?;
422 Ok(data
423 .get("playlist_clips")
424 .and_then(Value::as_array)
425 .map(|raw| {
426 raw.iter()
427 .map(|entry| {
428 let clip = entry
429 .get("clip")
430 .filter(|value| value.is_object())
431 .unwrap_or(entry);
432 Clip::from_json(clip)
433 })
434 .filter(|clip| !clip.id.is_empty())
435 .collect()
436 })
437 .unwrap_or_default())
438}
439
440fn keep_clip(raw: &Value) -> bool {
442 if raw.get("status").and_then(Value::as_str) != Some("complete") {
443 return false;
444 }
445 let metadata = raw.get("metadata");
446 let clip_type = metadata.and_then(|m| m.get("type")).and_then(Value::as_str);
447 if clip_type.is_some_and(|t| EXCLUDED_TYPES.contains(&t)) {
448 return false;
449 }
450 let task = metadata.and_then(|m| m.get("task")).and_then(Value::as_str);
451 !task.is_some_and(|t| EXCLUDED_TASKS.contains(&t))
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use crate::testutil::{MockHttp, RecordingClock, Reply, Rule, ScriptedHttp};
458 use std::time::Duration;
459
460 fn feed_body() -> String {
461 serde_json::json!({
462 "has_more": false,
463 "clips": [
464 {
465 "id": "a", "title": "Song A", "status": "complete",
466 "audio_url": "https://cdn1.suno.ai/a.mp3",
467 "metadata": {"tags": "rock", "duration": 120.5, "type": "gen"}
468 },
469 {"id": "b", "title": "Infill", "status": "complete", "metadata": {"task": "infill"}},
470 {"id": "c", "title": "Streaming", "status": "streaming", "metadata": {}},
471 {
472 "id": "d", "title": "Context", "status": "complete",
473 "metadata": {"type": "rendered_context_window"}
474 }
475 ]
476 })
477 .to_string()
478 }
479
480 #[test]
481 fn parse_feed_filters_and_maps() {
482 let (clips, has_more) = parse_feed(feed_body().as_bytes()).unwrap();
483 assert!(!has_more);
484 assert_eq!(clips.len(), 1);
485 assert_eq!(clips[0].id, "a");
486 assert_eq!(clips[0].tags, "rock");
487 assert!((clips[0].duration - 120.5).abs() < f64::EPSILON);
488 }
489
490 #[test]
491 fn audiopipe_url_is_rewritten_to_cdn() {
492 let raw =
493 serde_json::json!({"id": "x", "audio_url": "https://audiopipe.suno.ai/?item_id=x"});
494 assert_eq!(
495 Clip::from_json(&raw).audio_url,
496 "https://cdn1.suno.ai/x.mp3"
497 );
498 }
499
500 #[test]
501 fn list_clips_authenticates_then_reads_the_feed() {
502 let client_body = serde_json::json!({
503 "response": {
504 "last_active_session_id": "s",
505 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
506 }
507 })
508 .to_string();
509 let http = MockHttp::new(vec![
510 Rule::new(
511 "/v1/client/sessions/",
512 200,
513 r#"{"jwt": "a.b.c"}"#.to_string(),
514 ),
515 Rule::new("/v1/client", 200, client_body),
516 Rule::new("/api/feed/v2", 200, feed_body()),
517 ]);
518
519 let mut auth = ClerkAuth::new("eyJtoken");
520 pollster::block_on(auth.authenticate(&http)).unwrap();
521 let mut client = SunoClient::new(auth, RecordingClock::new());
522 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
523 assert_eq!(clips.len(), 1);
524 assert_eq!(clips[0].id, "a");
525 assert!(complete);
526 }
527
528 #[test]
529 fn list_clips_reports_incomplete_when_paging_is_capped() {
530 let mut rules = auth_rules();
531 rules.push(Rule::new(
532 "/api/feed/v2",
533 200,
534 serde_json::json!({
535 "has_more": true,
536 "clips": [{
537 "id": "a", "title": "Song A", "status": "complete",
538 "audio_url": "https://cdn1.suno.ai/a.mp3",
539 "metadata": {"type": "gen"}
540 }]
541 })
542 .to_string(),
543 ));
544 let http = MockHttp::new(rules);
545 let mut client = authed_client(&http);
546
547 let (_clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
548 assert!(!complete);
549 }
550
551 fn auth_rules() -> Vec<Rule> {
552 let client_body = serde_json::json!({
553 "response": {
554 "last_active_session_id": "s",
555 "sessions": [{"id": "s", "user": {"id": "u", "username": "h"}}]
556 }
557 })
558 .to_string();
559 vec![
560 Rule::new(
561 "/v1/client/sessions/",
562 200,
563 r#"{"jwt": "a.b.c"}"#.to_string(),
564 ),
565 Rule::new("/v1/client", 200, client_body),
566 ]
567 }
568
569 fn authed_client(http: &MockHttp) -> SunoClient<RecordingClock> {
570 let mut auth = ClerkAuth::new("eyJtoken");
571 pollster::block_on(auth.authenticate(http)).unwrap();
572 SunoClient::new(auth, RecordingClock::new())
573 }
574
575 fn scripted_client(http: &ScriptedHttp, clock: RecordingClock) -> SunoClient<RecordingClock> {
576 let mut auth = ClerkAuth::new("eyJtoken");
577 pollster::block_on(auth.authenticate(http)).unwrap();
578 SunoClient::new(auth, clock)
579 }
580
581 fn one_clip_page(id: &str, has_more: bool) -> String {
582 serde_json::json!({
583 "has_more": has_more,
584 "clips": [{
585 "id": id, "title": "Song", "status": "complete",
586 "audio_url": format!("https://cdn1.suno.ai/{id}.mp3"),
587 "metadata": {"type": "gen"}
588 }]
589 })
590 .to_string()
591 }
592
593 #[test]
594 fn list_clips_retries_a_rate_limited_page() {
595 let http = ScriptedHttp::new().with_auth().route_seq(
596 "/api/feed/v2",
597 vec![Reply::status(429), Reply::json(&feed_body())],
598 );
599 let clock = RecordingClock::new();
600 let mut client = scripted_client(&http, clock.clone());
601
602 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
603 assert_eq!(clips.len(), 1);
604 assert!(complete);
605 assert_eq!(http.count("/api/feed/v2"), 2);
607 assert_eq!(clock.sleeps(), vec![Duration::from_secs(1)]);
608 }
609
610 #[test]
611 fn list_clips_honours_retry_after_on_a_throttled_page() {
612 let http = ScriptedHttp::new().with_auth().route_seq(
613 "/api/feed/v2",
614 vec![
615 Reply::status(429).with_retry_after(7),
616 Reply::json(&feed_body()),
617 ],
618 );
619 let clock = RecordingClock::new();
620 let mut client = scripted_client(&http, clock.clone());
621
622 let (clips, _complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
623 assert_eq!(clips.len(), 1);
624 assert_eq!(clock.sleeps(), vec![Duration::from_secs(7)]);
626 }
627
628 #[test]
629 fn list_clips_paces_between_pages() {
630 let http = ScriptedHttp::new().with_auth().route_seq(
631 "/api/feed/v2",
632 vec![
633 Reply::json(&one_clip_page("a", true)),
634 Reply::json(&one_clip_page("e", false)),
635 ],
636 );
637 let clock = RecordingClock::new();
638 let mut client = scripted_client(&http, clock.clone());
639
640 let (clips, complete) = pollster::block_on(client.list_clips(&http, false, None)).unwrap();
641 assert!(complete);
642 assert_eq!(clips.len(), 2);
643 assert_eq!(http.count("/api/feed/v2"), 2);
644 assert_eq!(clock.sleeps(), vec![crate::consts::FEED_PAGE_DELAY]);
646 }
647
648 #[test]
649 fn list_clips_gives_up_after_max_retries() {
650 let http = ScriptedHttp::new()
651 .with_auth()
652 .route("/api/feed/v2", Reply::status(429));
653 let clock = RecordingClock::new();
654 let mut client = scripted_client(&http, clock.clone());
655
656 let result = pollster::block_on(client.list_clips(&http, false, None));
657 assert!(matches!(result, Err(Error::RateLimited { .. })));
658 let budget = crate::consts::API_MAX_RETRIES as usize;
659 assert_eq!(clock.sleeps().len(), budget);
660 assert_eq!(http.count("/api/feed/v2"), budget + 1);
661 }
662
663 #[test]
664 fn parse_clip_accepts_bare_and_wrapped_shapes() {
665 let bare = serde_json::json!({"id": "z", "title": "Zed"}).to_string();
666 assert_eq!(parse_clip(bare.as_bytes()).unwrap().id, "z");
667
668 let wrapped = serde_json::json!({"clip": {"id": "w", "title": "Wai"}}).to_string();
669 assert_eq!(parse_clip(wrapped.as_bytes()).unwrap().id, "w");
670
671 let missing = serde_json::json!({"detail": "not found"}).to_string();
672 assert!(parse_clip(missing.as_bytes()).is_none());
673 }
674
675 #[test]
676 fn get_clip_uses_the_dedicated_endpoint() {
677 let clip_body = serde_json::json!({
678 "id": "z", "title": "Zed", "status": "complete",
679 "audio_url": "https://cdn1.suno.ai/z.mp3",
680 "metadata": {"tags": "jazz", "duration": 99.0, "type": "gen"}
681 })
682 .to_string();
683 let mut rules = auth_rules();
684 rules.push(Rule::new("/api/clip/", 200, clip_body));
685 let http = MockHttp::new(rules);
686 let mut client = authed_client(&http);
687
688 let clip = pollster::block_on(client.get_clip(&http, "z")).unwrap();
689 assert_eq!(clip.id, "z");
690 assert_eq!(clip.title, "Zed");
691 assert_eq!(clip.tags, "jazz");
692 }
693
694 #[test]
695 fn get_clip_falls_back_to_the_feed_when_endpoint_missing() {
696 let mut rules = auth_rules();
697 rules.push(Rule::new(
698 "/api/clip/",
699 404,
700 r#"{"detail": "not found"}"#.to_string(),
701 ));
702 rules.push(Rule::new("/api/feed/v2", 200, feed_body()));
703 let http = MockHttp::new(rules);
704 let mut client = authed_client(&http);
705
706 let clip = pollster::block_on(client.get_clip(&http, "a")).unwrap();
707 assert_eq!(clip.id, "a");
708 assert_eq!(clip.tags, "rock");
709 }
710
711 #[test]
712 fn request_wav_accepts_a_2xx_status() {
713 let mut rules = auth_rules();
714 rules.push(Rule::new("/convert_wav/", 201, "{}".to_string()));
715 let http = MockHttp::new(rules);
716 let mut client = authed_client(&http);
717
718 assert!(pollster::block_on(client.request_wav(&http, "z")).is_ok());
719 }
720
721 #[test]
722 fn wav_url_reads_the_ready_url() {
723 let mut rules = auth_rules();
724 rules.push(Rule::new(
725 "/wav_file/",
726 200,
727 r#"{"wav_file_url": "https://cdn1.suno.ai/z.wav"}"#.to_string(),
728 ));
729 let http = MockHttp::new(rules);
730 let mut client = authed_client(&http);
731
732 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
733 assert_eq!(url.as_deref(), Some("https://cdn1.suno.ai/z.wav"));
734 }
735
736 #[test]
737 fn wav_url_is_none_until_the_render_is_ready() {
738 let mut rules = auth_rules();
739 rules.push(Rule::new("/wav_file/", 200, "{}".to_string()));
740 let http = MockHttp::new(rules);
741 let mut client = authed_client(&http);
742
743 let url = pollster::block_on(client.wav_url(&http, "z")).unwrap();
744 assert_eq!(url, None);
745 }
746
747 #[test]
748 fn get_clips_by_ids_uses_the_ids_filter_and_keeps_all_clips() {
749 let feed = serde_json::json!({
752 "clips": [
753 {
754 "id": "p1", "title": "Infill Ancestor", "status": "complete",
755 "metadata": {"type": "gen", "task": "infill"}
756 },
757 {
758 "id": "p2", "title": "Uploaded Root", "status": "complete",
759 "metadata": {"type": "upload"}
760 }
761 ]
762 })
763 .to_string();
764 let mut rules = auth_rules();
765 rules.push(Rule::new("/api/feed/v2/?ids=p1,p2", 200, feed));
767 let http = MockHttp::new(rules);
768 let mut client = authed_client(&http);
769
770 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["p1", "p2"])).unwrap();
771 assert_eq!(
772 clips.len(),
773 2,
774 "infill and upload ancestors must not be filtered"
775 );
776 assert_eq!(clips[0].id, "p1");
777 assert_eq!(clips[1].id, "p2");
778 }
779
780 #[test]
781 fn get_clips_by_ids_accepts_a_bare_array_body() {
782 let body = serde_json::json!([
783 {"id": "only", "title": "Bare", "status": "complete", "metadata": {"type": "gen"}}
784 ])
785 .to_string();
786 let mut rules = auth_rules();
787 rules.push(Rule::new("/api/feed/v2/?ids=only", 200, body));
788 let http = MockHttp::new(rules);
789 let mut client = authed_client(&http);
790
791 let clips = pollster::block_on(client.get_clips_by_ids(&http, &["only"])).unwrap();
792 assert_eq!(clips.len(), 1);
793 assert_eq!(clips[0].id, "only");
794 }
795
796 #[test]
797 fn get_clip_parent_reads_the_parent_clip() {
798 let parent = serde_json::json!({
799 "id": "par", "title": "Ancestor", "status": "complete",
800 "metadata": {"type": "gen"}
801 })
802 .to_string();
803 let mut rules = auth_rules();
804 rules.push(Rule::new("/api/clips/parent?clip_id=child", 200, parent));
805 let http = MockHttp::new(rules);
806 let mut client = authed_client(&http);
807
808 let clip = pollster::block_on(client.get_clip_parent(&http, "child")).unwrap();
809 assert_eq!(clip.unwrap().id, "par");
810 }
811
812 #[test]
813 fn get_clip_parent_is_none_for_a_root() {
814 let mut rules = auth_rules();
815 rules.push(Rule::new(
816 "/api/clips/parent",
817 404,
818 r#"{"detail": "no parent"}"#.to_string(),
819 ));
820 let http = MockHttp::new(rules);
821 let mut client = authed_client(&http);
822
823 let clip = pollster::block_on(client.get_clip_parent(&http, "root")).unwrap();
824 assert!(clip.is_none());
825 }
826
827 #[test]
828 fn get_clip_parent_propagates_server_errors_instead_of_reporting_no_parent() {
829 for status in [500u16, 503] {
833 let mut rules = auth_rules();
834 rules.push(Rule::new(
835 "/api/clips/parent",
836 status,
837 r#"{"detail": "server error"}"#.to_string(),
838 ));
839 let http = MockHttp::new(rules);
840 let mut client = authed_client(&http);
841
842 let result = pollster::block_on(client.get_clip_parent(&http, "child"));
843 assert!(
844 matches!(result, Err(Error::Api(_))),
845 "status {status} must propagate as an error, not Ok(None)"
846 );
847 }
848 }
849
850 #[test]
851 fn get_playlists_maps_entries_and_skips_missing_ids() {
852 let page1 = serde_json::json!({
853 "playlists": [
854 {"id": "pl1", "name": "Road Trip", "num_total_results": 12},
855 {"id": "", "name": "No Id", "num_total_results": 3},
856 {"name": "Also No Id"}
857 ]
858 })
859 .to_string();
860 let mut rules = auth_rules();
861 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
863 rules.push(Rule::new(
864 "/api/playlist/me?page=2",
865 200,
866 r#"{"playlists": []}"#.to_string(),
867 ));
868 let http = MockHttp::new(rules);
869 let mut client = authed_client(&http);
870
871 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
872 assert_eq!(playlists.len(), 1, "entries without an id are dropped");
873 assert_eq!(
874 playlists[0],
875 Playlist {
876 id: "pl1".to_owned(),
877 name: "Road Trip".to_owned(),
878 num_clips: 12,
879 }
880 );
881 }
882
883 #[test]
884 fn get_playlists_defaults_a_missing_name_to_untitled() {
885 let page1 = serde_json::json!({
886 "playlists": [{"id": "pl9", "num_total_results": 1}]
887 })
888 .to_string();
889 let mut rules = auth_rules();
890 rules.push(Rule::new("/api/playlist/me?page=1", 200, page1));
891 rules.push(Rule::new(
892 "/api/playlist/me?page=2",
893 200,
894 r#"{"playlists": []}"#.to_string(),
895 ));
896 let http = MockHttp::new(rules);
897 let mut client = authed_client(&http);
898
899 let playlists = pollster::block_on(client.get_playlists(&http)).unwrap();
900 assert_eq!(playlists[0].name, "Untitled");
901 }
902
903 #[test]
904 fn get_playlist_clips_preserves_order_and_unwraps_clip() {
905 let body = serde_json::json!({
908 "playlist_clips": [
909 {"clip": {
910 "id": "second", "title": "Second", "status": "complete",
911 "metadata": {"duration": 60.0, "type": "gen"}
912 }},
913 {"clip": {
914 "id": "first", "title": "First", "status": "complete",
915 "metadata": {"duration": 30.0, "task": "infill", "type": "gen"}
916 }}
917 ]
918 })
919 .to_string();
920 let mut rules = auth_rules();
921 rules.push(Rule::new("/api/playlist/pl1/", 200, body));
922 let http = MockHttp::new(rules);
923 let mut client = authed_client(&http);
924
925 let clips = pollster::block_on(client.get_playlist_clips(&http, "pl1")).unwrap();
926 assert_eq!(clips.len(), 2, "an infill member is not filtered out");
927 assert_eq!(clips[0].id, "second");
928 assert_eq!(clips[1].id, "first");
929 }
930
931 #[test]
932 fn get_playlist_clips_is_empty_for_a_playlist_with_no_members() {
933 let mut rules = auth_rules();
934 rules.push(Rule::new(
935 "/api/playlist/empty/",
936 200,
937 r#"{"playlist_clips": []}"#.to_string(),
938 ));
939 let http = MockHttp::new(rules);
940 let mut client = authed_client(&http);
941
942 let clips = pollster::block_on(client.get_playlist_clips(&http, "empty")).unwrap();
943 assert!(clips.is_empty());
944 }
945}