1use 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#[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 {
35 auth: ClerkAuth,
36}
37
38impl SunoClient {
39 pub fn new(auth: ClerkAuth) -> Self {
41 Self { auth }
42 }
43
44 pub fn auth(&self) -> &ClerkAuth {
46 &self.auth
47 }
48
49 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 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 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 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 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 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 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 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 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 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 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 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
266fn 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
281fn 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
305fn 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
328fn 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
339fn 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
364fn 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
392fn 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 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 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 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 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 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}