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