suno_core/client.rs
1//! The Suno API client: lists the library behind the [`Http`](crate::Http) port.
2
3use std::collections::{BTreeSet, HashMap};
4use std::sync::Mutex;
5use std::time::Instant;
6
7use futures_util::stream::{self, StreamExt};
8
9use crate::auth::ClerkAuth;
10use crate::backoff::{backoff_delay, retry_after};
11use crate::clock::Clock;
12use crate::consts::{
13 API_MAX_RETRIES, BILLING_INFO_PATH, CLIP_PARENT_PATH, FEED_INITIAL_RATE, FEED_V3_PATH,
14 GET_SONGS_BY_IDS_PATH, GET_SONGS_CHUNK, MAX_PAGES, PLAYLIST_ME_PATH, PLAYLIST_PATH,
15 SUNO_API_BASE_URL,
16};
17use crate::error::{Error, Result};
18use crate::http::{Http, HttpRequest, Method};
19use crate::limiter::{AdaptiveLimiter, retry_after_delay};
20use crate::lyrics::AlignedLyrics;
21use crate::model::{BillingInfo, Clip, Playlist, Stem};
22use crate::wire::{
23 feed_v3_body, parse_aligned_lyrics, parse_billing_info, parse_clip, parse_feed_v3,
24 parse_playlist_clips, parse_playlists, parse_songs_batch, parse_stem_page_count,
25 parse_stems_page, parse_wav_url,
26};
27
28/// A client for the Suno library API, owning the account's [`ClerkAuth`].
29///
30/// Holds the [`Clock`] so `api_request` can back off on a
31/// `429` or transient failure, and an `AdaptiveLimiter` that paces reactively:
32/// it waits nowhere until a `429`, then halves the rate and ramps it back on
33/// sustained success.
34pub struct SunoClient<C> {
35 auth: ClerkAuth,
36 clock: C,
37 limiter: Mutex<AdaptiveLimiter>,
38}
39
40impl<C: Clock> SunoClient<C> {
41 /// Create a client from a fresh or already-authenticated [`ClerkAuth`].
42 pub fn new(auth: ClerkAuth, clock: C) -> Self {
43 Self {
44 auth,
45 clock,
46 limiter: Mutex::new(AdaptiveLimiter::new(FEED_INITIAL_RATE)),
47 }
48 }
49
50 /// Borrow the underlying authenticator.
51 pub fn auth(&self) -> &ClerkAuth {
52 &self.auth
53 }
54
55 /// Lock the adaptive-limiter mutex, panicking on poison. Single access
56 /// point; the guarded sections only read or update pacing counters and
57 /// never panic, so poison is unreachable.
58 #[allow(clippy::unwrap_used)]
59 fn locked_limiter(&self) -> std::sync::MutexGuard<'_, AdaptiveLimiter> {
60 self.limiter.lock().unwrap()
61 }
62
63 /// The adaptive limiter's current requests-per-second rate, for tests.
64 #[cfg(test)]
65 pub(crate) fn limiter_rate(&self) -> f64 {
66 self.locked_limiter().rate()
67 }
68
69 /// List clips across the whole library, or only liked clips.
70 ///
71 /// Walks the cursor-paginated `POST /api/feed/v3` feed, hard-capped at
72 /// `MAX_PAGES`, truncating to `limit` when set.
73 ///
74 /// The `complete` flag is `true` only when paging ended on a server-reported
75 /// `has_more == false`; any other stop (missing `has_more`, no usable cursor,
76 /// `limit`, `MAX_PAGES`, transport error) yields `false` so the caller
77 /// never treats a truncated listing as authoritative for deletion.
78 /// `any_filtered` is `true` when the downloadable filter dropped any clip,
79 /// which likewise denies deletion authority since it may have hidden a
80 /// manifest-tracked clip.
81 pub async fn list_clips(
82 &self,
83 http: &impl Http,
84 liked: bool,
85 limit: Option<usize>,
86 ) -> Result<(Vec<Clip>, bool, bool)> {
87 let mut clips = Vec::new();
88 let mut cursor: Option<String> = None;
89 let mut complete = false;
90 let mut any_filtered = false;
91 for _ in 0..MAX_PAGES {
92 let body = feed_v3_body(liked, cursor.as_deref());
93 let response = self
94 .api_send_retrying(http, Method::Post, FEED_V3_PATH, body)
95 .await?;
96 let page = parse_feed_v3(&response)?;
97 clips.extend(page.clips);
98 any_filtered |= page.any_filtered;
99 match page.has_more {
100 Some(false) => {
101 complete = true;
102 break;
103 }
104 Some(true) => match page.next_cursor {
105 Some(next) => cursor = Some(next),
106 None => break,
107 },
108 None => break,
109 }
110 if limit.is_some_and(|n| clips.len() >= n) {
111 break;
112 }
113 }
114 if let Some(n) = limit {
115 clips.truncate(n);
116 }
117 Ok((clips, complete, any_filtered))
118 }
119
120 /// Fetch one clip by ID.
121 ///
122 /// Tries the dedicated `/api/clip/{id}` endpoint first, then falls back to
123 /// scanning the library feed if that endpoint yields no matching clip.
124 pub async fn get_clip(&self, http: &impl Http, id: &str) -> Result<Clip> {
125 if let Some(clip) = self.try_get_clip(http, id).await? {
126 return Ok(clip);
127 }
128 self.find_in_feed(http, id).await
129 }
130
131 /// Ask Suno to render a clip to lossless WAV (server-side, asynchronous).
132 pub async fn request_wav(&self, http: &impl Http, id: &str) -> Result<()> {
133 let path = format!("/api/gen/{id}/convert_wav/");
134 self.api_request(http, Method::Post, &path, Vec::new())
135 .await?;
136 Ok(())
137 }
138
139 /// Read the rendered WAV URL for a clip, or `None` while it is not ready.
140 ///
141 /// A `404` maps to `None` (not yet rendered), and it skips the shared retry
142 /// so the caller's poll loop owns that budget.
143 pub async fn wav_url(&self, http: &impl Http, id: &str) -> Result<Option<String>> {
144 let path = format!("/api/gen/{id}/wav_file/");
145 let body = match self.api_get(http, &path).await {
146 Ok(body) => body,
147 Err(Error::NotFound(_)) => return Ok(None),
148 Err(err) => return Err(err),
149 };
150 parse_wav_url(&body)
151 }
152
153 /// Fetch a clip's word- and line-level aligned (synced) lyrics.
154 ///
155 /// The trailing slash on `.../aligned_lyrics/v2/` is required. An
156 /// instrumental or un-alignable clip returns `200` with empty arrays, and a
157 /// `404` is treated the same way, so an absent alignment is "no synced
158 /// lyrics" rather than a run failure.
159 pub async fn aligned_lyrics(&self, http: &impl Http, id: &str) -> Result<AlignedLyrics> {
160 let path = format!("/api/gen/{id}/aligned_lyrics/v2/");
161 match self.api_get_retrying(http, &path).await {
162 Ok(body) => Ok(parse_aligned_lyrics(&body)),
163 Err(Error::NotFound(_)) => Ok(AlignedLyrics::default()),
164 Err(err) => Err(err),
165 }
166 }
167
168 /// Fetch specific clips by id, batch-first with a per-id fallback.
169 ///
170 /// Used by lineage resolution to gap-fill ancestors absent from a normal
171 /// listing, including trashed ones. Ids are fetched in one batch via
172 /// [`get_songs_by_ids`](Self::get_songs_by_ids); any the batch omits fall
173 /// back to one `GET /api/clip/{id}` each, bounded by `concurrency`, attempted
174 /// once, with a `404` skipped. A `429` while batching propagates rather than
175 /// fanning out into per-id requests.
176 ///
177 /// No downloadability filter is applied: an ancestor may be an infill or
178 /// context artefact the walk must still traverse, so these clips are for
179 /// resolution only and must never be treated as download candidates. Ids are
180 /// deduplicated in order and the result preserves that order, matched by id.
181 pub async fn get_clips_by_ids(
182 &self,
183 http: &impl Http,
184 ids: &[&str],
185 concurrency: usize,
186 ) -> Result<Vec<Clip>> {
187 let ordered = dedup_nonempty(ids);
188 let mut found: HashMap<&str, Clip> = self
189 .get_songs_by_ids(http, &ordered)
190 .await?
191 .into_iter()
192 .filter_map(|clip| {
193 ordered
194 .iter()
195 .find(|id| **id == clip.id)
196 .map(|id| (*id, clip))
197 })
198 .collect();
199 let omitted: Vec<&str> = ordered
200 .iter()
201 .copied()
202 .filter(|id| !found.contains_key(id))
203 .collect();
204 if !omitted.is_empty() {
205 for clip in self
206 .fetch_clips_individually(http, &omitted, concurrency)
207 .await?
208 {
209 if let Some(id) = ordered.iter().copied().find(|id| *id == clip.id) {
210 found.insert(id, clip);
211 }
212 }
213 }
214 Ok(ordered.iter().filter_map(|id| found.remove(id)).collect())
215 }
216
217 /// Batch-fetch clips by id via `GET /api/clips/get_songs_by_ids?ids=…&ids=…`.
218 ///
219 /// The pure batch primitive: deduplicated ids are split into
220 /// `GET_SONGS_CHUNK` chunks and matched back by id, preserving input order.
221 /// Ids the batch does not return are left for the caller to fill.
222 ///
223 /// The endpoint is undocumented and may be unavailable: a chunk it cannot
224 /// serve (any `4xx`/`5xx`, transport failure, or unexpected body) yields
225 /// nothing for that chunk rather than erroring, so an outage degrades rather
226 /// than breaks. A `429` rides the retry and then propagates rather than
227 /// fanning out into per-id requests; an auth failure likewise propagates.
228 pub async fn get_songs_by_ids(&self, http: &impl Http, ids: &[&str]) -> Result<Vec<Clip>> {
229 let ordered = dedup_nonempty(ids);
230 let mut found: HashMap<&str, Clip> = HashMap::new();
231 for chunk in ordered.chunks(GET_SONGS_CHUNK) {
232 let query = chunk
233 .iter()
234 .map(|id| format!("ids={id}"))
235 .collect::<Vec<_>>()
236 .join("&");
237 let path = format!("{GET_SONGS_BY_IDS_PATH}?{query}");
238 let clips = match self.api_get_retrying(http, &path).await {
239 Ok(body) => parse_songs_batch(&body).unwrap_or_default(),
240 Err(err @ (Error::RateLimited { .. } | Error::Auth(_))) => return Err(err),
241 Err(_) => Vec::new(),
242 };
243 for clip in clips {
244 if let Some(id) = chunk.iter().copied().find(|id| *id == clip.id) {
245 found.insert(id, clip);
246 }
247 }
248 }
249 Ok(ordered.iter().filter_map(|id| found.remove(id)).collect())
250 }
251
252 /// The per-id `GET /api/clip/{id}` fallback for
253 /// [`get_clips_by_ids`](Self::get_clips_by_ids), with bounded concurrency.
254 /// Returns any clip (trashed or artefact) unfiltered; a `404` is skipped and
255 /// input order is preserved.
256 async fn fetch_clips_individually(
257 &self,
258 http: &impl Http,
259 ids: &[&str],
260 concurrency: usize,
261 ) -> Result<Vec<Clip>> {
262 let limit = concurrency.max(1);
263 let fetched = stream::iter(ids.iter().copied())
264 .map(|id| async move {
265 let path = format!("/api/clip/{id}");
266 match self.api_get_retrying(http, &path).await {
267 Ok(body) => Ok(parse_clip(&body)),
268 Err(Error::NotFound(_)) => Ok(None),
269 Err(err) => Err(err),
270 }
271 })
272 .buffered(limit)
273 .collect::<Vec<_>>()
274 .await;
275 let mut clips = Vec::new();
276 for item in fetched {
277 if let Some(clip) = item? {
278 clips.push(clip);
279 }
280 }
281 Ok(clips)
282 }
283
284 /// Fetch a clip's immediate parent, or `None` when the clip is a root.
285 ///
286 /// A root's parent comes back as `200` with a bodiless, id-less clip (not a
287 /// `404`); `parse_clip` gates on a non-empty id so that maps to `Ok(None)`.
288 /// The `404` arm is a fallback for the alternative encoding. Any other
289 /// failure propagates rather than being mistaken for a root.
290 pub async fn get_clip_parent(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
291 let path = format!("{CLIP_PARENT_PATH}?clip_id={id}");
292 match self.api_get_retrying(http, &path).await {
293 Ok(body) => Ok(parse_clip(&body)),
294 Err(Error::NotFound(_)) => Ok(None),
295 Err(err) => Err(err),
296 }
297 }
298
299 /// List the account's own playlists, paging `/api/playlist/me`.
300 ///
301 /// Trashed and share-list playlists are excluded by query. Paging stops on
302 /// the first empty page, is hard-capped at `MAX_PAGES`, and de-duplicates
303 /// by id so a server that ignores the page parameter cannot loop or inflate
304 /// the set.
305 ///
306 /// A hard failure propagates: the caller then refuses every playlist
307 /// deletion this run, so a dropped fetch can never remove a `.m3u8`.
308 pub async fn get_playlists(&self, http: &impl Http) -> Result<Vec<Playlist>> {
309 let mut playlists = Vec::new();
310 let mut seen = BTreeSet::new();
311 for page in 1..=MAX_PAGES {
312 let path =
313 format!("{PLAYLIST_ME_PATH}?page={page}&show_trashed=false&show_sharelist=false");
314 let body = self.api_get_retrying(http, &path).await?;
315 let page_playlists = parse_playlists(&body)?;
316 if page_playlists.is_empty() {
317 break;
318 }
319 for playlist in page_playlists {
320 if seen.insert(playlist.id.clone()) {
321 playlists.push(playlist);
322 }
323 }
324 }
325 Ok(playlists)
326 }
327
328 /// Fetch one playlist's clips in Suno order via `/api/playlist/{id}/`.
329 ///
330 /// `playlist_clips[]` is already ordered with trashed members excluded, so
331 /// order is preserved and no downloadability filter is applied. Only clips
332 /// with a non-empty id are kept.
333 ///
334 /// The returned `bool` is a completeness signal for deletion authority:
335 /// `true` only when `num_total_results` is present, equals the raw count, and
336 /// no member was dropped for a missing id. A short or id-missing page returns
337 /// `false`, so a Mirror playlist under `library = "off"` is never
338 /// authoritative unless its whole member set was seen.
339 pub async fn get_playlist_clips(
340 &self,
341 http: &impl Http,
342 id: &str,
343 ) -> Result<(Vec<Clip>, bool)> {
344 let path = format!("{PLAYLIST_PATH}{id}/");
345 let body = self.api_get_retrying(http, &path).await?;
346 parse_playlist_clips(&body)
347 }
348
349 /// Read the authenticated account's billing information.
350 pub async fn get_billing_info(&self, http: &impl Http) -> Result<BillingInfo> {
351 let body = self.api_get_retrying(http, BILLING_INFO_PATH).await?;
352 parse_billing_info(&body)
353 }
354
355 /// List a clip's already-separated stems (free, read-only).
356 ///
357 /// Pages `GET /api/clip/{id}/stems/pages` then `.../stems?page=P` (0-indexed).
358 /// This endpoint only reads: it never spends credits or triggers separation,
359 /// so it is safe on the bulk mirror path. Call only when the clip's
360 /// `has_stem` is true.
361 ///
362 /// The `complete` flag is `true` only when the page count came back and every
363 /// page drained after at least one stem was seen. An empty listing
364 /// (`pages == 0` or a `400`/`404` on the page-count endpoint), a transport
365 /// failure, a partial drain, or a count above `MAX_PAGES` all yield
366 /// `false`, so the caller KEEPS existing local stems rather than reading the
367 /// absence as "no stems".
368 pub async fn list_stems(&self, http: &impl Http, clip_id: &str) -> Result<(Vec<Stem>, bool)> {
369 let declared = self.stem_page_count(http, clip_id).await?;
370 // Zero pages is Suno's "no stems" answer: indeterminate, never an
371 // authoritative empty.
372 if declared == 0 {
373 return Ok((Vec::new(), false));
374 }
375 let pages = declared.min(MAX_PAGES);
376 let mut stems: Vec<Stem> = Vec::new();
377 for page in 0..pages {
378 // Pages are 0-indexed; no trailing slash before the query (unlike
379 // `.../stems/pages`).
380 let path = format!("/api/clip/{clip_id}/stems?page={page}");
381 // A page error mid-enumeration is indeterminate: surface it so the
382 // caller keeps existing stems rather than reading a partial drain as
383 // authoritative.
384 let body = self.api_get_retrying(http, &path).await?;
385 stems.extend(parse_stems_page(&body));
386 }
387 dedupe_stems(&mut stems);
388 let complete = !stems.is_empty() && declared <= MAX_PAGES;
389 Ok((stems, complete))
390 }
391
392 /// Read the stems page count from `GET /api/clip/{id}/stems/pages`.
393 ///
394 /// A clip with no stems answers `400`/`404`; both map to `0` (indeterminate,
395 /// never an authoritative empty). Any other error propagates so the caller
396 /// keeps the stems as unknown.
397 async fn stem_page_count(&self, http: &impl Http, clip_id: &str) -> Result<u32> {
398 let path = format!("/api/clip/{clip_id}/stems/pages");
399 match self.api_get_retrying(http, &path).await {
400 Ok(body) => Ok(parse_stem_page_count(&body)),
401 Err(err) if is_invalid_page_error(&err) => Ok(0),
402 Err(Error::NotFound(_)) => Ok(0),
403 Err(err) => Err(err),
404 }
405 }
406
407 /// Try the dedicated clip endpoint, returning `None` when it is missing or
408 /// returns a body that does not yield the requested clip.
409 async fn try_get_clip(&self, http: &impl Http, id: &str) -> Result<Option<Clip>> {
410 let path = format!("/api/clip/{id}");
411 match self.api_get_retrying(http, &path).await {
412 Ok(body) => Ok(parse_clip(&body).filter(|clip| clip.id == id)),
413 Err(Error::NotFound(_)) => Ok(None),
414 Err(err) => Err(err),
415 }
416 }
417
418 /// Locate a clip by scanning the library feed.
419 async fn find_in_feed(&self, http: &impl Http, id: &str) -> Result<Clip> {
420 let (clips, _complete, _) = self.list_clips(http, false, None).await?;
421 clips
422 .into_iter()
423 .find(|clip| clip.id == id)
424 .ok_or_else(|| Error::Api(format!("clip {id} not found in the library")))
425 }
426
427 /// Perform an authenticated GET, refreshing the JWT once on a 401/403.
428 async fn api_get(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
429 self.api_request(http, Method::Get, path, Vec::new()).await
430 }
431
432 /// A retrying GET: [`api_send_retrying`](Self::api_send_retrying) with no body.
433 async fn api_get_retrying(&self, http: &impl Http, path: &str) -> Result<Vec<u8>> {
434 self.api_send_retrying(http, Method::Get, path, Vec::new())
435 .await
436 }
437
438 /// Like [`api_request`](Self::api_request) but paces through the adaptive
439 /// rate limiter and backs off via the [`Clock`] on a `429` or transient
440 /// failure, up to [`API_MAX_RETRIES`] times. Each attempt reconstructs the
441 /// request, so a throttled feed page re-POSTs the same cursor rather than
442 /// skipping ahead.
443 ///
444 /// Pacing lives at this single per-request layer so it composes with any
445 /// paged walk. The WAV render flow instead uses the plain
446 /// [`api_get`](Self::api_get) so the executor owns that retry budget.
447 async fn api_send_retrying(
448 &self,
449 http: &impl Http,
450 method: Method,
451 path: &str,
452 body: Vec<u8>,
453 ) -> Result<Vec<u8>> {
454 let pace = self.locked_limiter().pace(Instant::now());
455 if !pace.is_zero() {
456 self.clock.sleep(pace).await;
457 }
458 let mut retries = 0;
459 loop {
460 match self.api_request(http, method, path, body.clone()).await {
461 Ok(response) => return Ok(response),
462 Err(Error::RateLimited { retry_after }) if retries < API_MAX_RETRIES => {
463 self.clock.sleep(retry_after_delay(retry_after)).await;
464 retries += 1;
465 }
466 Err(Error::Connection(_)) if retries < API_MAX_RETRIES => {
467 self.clock.sleep(backoff_delay(retries, None)).await;
468 retries += 1;
469 }
470 Err(err) => return Err(err),
471 }
472 }
473 }
474
475 /// Perform an authenticated request, refreshing the JWT once on a 401/403.
476 ///
477 /// `body` is sent only by the adapter when non-empty, so a GET or a bodyless
478 /// POST reaches the network unchanged.
479 async fn api_request(
480 &self,
481 http: &impl Http,
482 method: Method,
483 path: &str,
484 body: Vec<u8>,
485 ) -> Result<Vec<u8>> {
486 // Crate-wide POST allow-list: every mutating request funnels through
487 // here, so a destructive or credit-spending endpoint can never be sent.
488 // GETs are free and unrestricted.
489 if method == Method::Post && !post_path_allowed(path) {
490 return Err(Error::Refused(format!(
491 "POST to {path} is not on the allow-list"
492 )));
493 }
494 let url = format!("{SUNO_API_BASE_URL}{path}");
495 let mut auth_refreshed = false;
496 loop {
497 let jwt = self.auth.ensure_jwt(self.clock.now_unix(), http).await?;
498 let mut request = match method {
499 Method::Get => HttpRequest::get(url.clone()),
500 Method::Post => HttpRequest::post(url.clone(), body.clone()),
501 };
502 request
503 .headers
504 .push(("Authorization".to_string(), format!("Bearer {jwt}")));
505 let response = http
506 .send(request)
507 .await
508 .map_err(|err| Error::Connection(err.to_string()))?;
509 match response.status {
510 200..=299 => {
511 self.locked_limiter().on_success();
512 return Ok(response.body);
513 }
514 401 | 403 if !auth_refreshed => {
515 self.auth.invalidate_jwt();
516 auth_refreshed = true;
517 }
518 401 | 403 => {
519 return Err(Error::Auth(format!(
520 "Suno API auth failed with status {}",
521 response.status
522 )));
523 }
524 429 => {
525 self.locked_limiter().on_rate_limit();
526 return Err(Error::RateLimited {
527 retry_after: retry_after(&response),
528 });
529 }
530 400 => {
531 let preview: String = String::from_utf8_lossy(&response.body)
532 .chars()
533 .take(200)
534 .collect();
535 return Err(Error::BadRequest(format!(
536 "Suno API returned 400: {preview}"
537 )));
538 }
539 404 => {
540 return Err(Error::NotFound(format!("Suno API returned 404: {path}")));
541 }
542 status => {
543 let preview: String = String::from_utf8_lossy(&response.body)
544 .chars()
545 .take(200)
546 .collect();
547 return Err(Error::Api(format!("Suno API returned {status}: {preview}")));
548 }
549 }
550 }
551 }
552}
553
554/// Whether a Suno API path may be the target of a POST (the crate-wide POST
555/// allow-list), deliberately narrow so a mutating request only ever reaches a
556/// vetted endpoint: the library feed ([`FEED_V3_PATH`]) and the per-clip WAV
557/// render (`…/convert_wav/`). Any credit-spending endpoint is deliberately
558/// absent; GETs are never gated.
559fn post_path_allowed(path: &str) -> bool {
560 if path == FEED_V3_PATH {
561 return true;
562 }
563 if let Some(rest) = path.strip_prefix("/api/gen/")
564 && let Some(id) = rest.strip_suffix("/convert_wav/")
565 {
566 return is_single_id_segment(id);
567 }
568 false
569}
570
571/// Whether `segment` is a single, non-empty path id segment: no slash, no query,
572/// and no `..` traversal, so an allow-list match can never be smuggled past by a
573/// crafted path.
574fn is_single_id_segment(segment: &str) -> bool {
575 !segment.is_empty()
576 && !segment.contains('/')
577 && !segment.contains('?')
578 && !segment.contains("..")
579}
580
581/// Whether an error is Suno's "no stems" answer (a `400`) on the page-count
582/// endpoint, distinguished from a transient `5xx` so a server error is never
583/// mistaken for "no stems".
584fn is_invalid_page_error(err: &Error) -> bool {
585 matches!(err, Error::BadRequest(_))
586}
587
588/// Drop stems that repeat across pages, keeping the first occurrence of each
589/// download URL so a paged listing counts a stem once.
590fn dedupe_stems(stems: &mut Vec<Stem>) {
591 let mut seen = BTreeSet::new();
592 stems.retain(|stem| seen.insert(stem.url.clone()));
593}
594
595/// Deduplicate ids in first-seen order, dropping empties. Shared by the by-id
596/// fetch paths so the batch, the fallback, and the returned order all agree.
597fn dedup_nonempty<'a>(ids: &[&'a str]) -> Vec<&'a str> {
598 let mut seen: BTreeSet<&str> = BTreeSet::new();
599 ids.iter()
600 .copied()
601 .filter(|id| !id.is_empty() && seen.insert(id))
602 .collect()
603}
604
605#[cfg(test)]
606mod tests;