Skip to main content

rspotify_s/clients/
base.rs

1use crate::{
2    auth_urls,
3    clients::{
4        convert_result,
5        pagination::{paginate, paginate_with_ctx, Paginator},
6    },
7    http::{BaseHttpClient, Form, Headers, HttpClient, Query},
8    join_ids,
9    model::*,
10    sync::Mutex,
11    util::build_map,
12    ClientError, ClientResult, Config, Credentials, Token,
13};
14
15use std::{collections::HashMap, fmt, ops::Not, sync::Arc};
16
17use chrono::Utc;
18use maybe_async::maybe_async;
19use serde_json::Value;
20
21/// This trait implements the basic endpoints from the Spotify API that may be
22/// accessed without user authorization, including parts of the authentication
23/// flow that are shared, and the endpoints.
24#[cfg_attr(target_arch = "wasm32", maybe_async(?Send))]
25#[cfg_attr(not(target_arch = "wasm32"), maybe_async)]
26pub trait BaseClient
27where
28    Self: Send + Sync + Default + Clone + fmt::Debug,
29{
30    fn get_config(&self) -> &Config;
31    fn get_http(&self) -> &HttpClient;
32    fn get_creds(&self) -> &Credentials;
33
34    /// Note that the token is wrapped by a `Mutex` in order to allow interior
35    /// mutability. This is required so that the entire client doesn't have to
36    /// be mutable (the token is accessed to from every endpoint).
37    fn get_token(&self) -> Arc<Mutex<Option<Token>>>;
38
39    /// Returns the absolute URL for an endpoint in the API.
40    fn api_url(&self, url: &str) -> String {
41        let mut base = self.get_config().api_base_url.clone();
42        if !base.ends_with('/') {
43            base.push('/');
44        }
45        base + url
46    }
47
48    /// Returns the absolute URL for an authentication step in the API.
49    fn auth_url(&self, url: &str) -> String {
50        let mut base = self.get_config().auth_base_url.clone();
51        if !base.ends_with('/') {
52            base.push('/');
53        }
54        base + url
55    }
56
57    /// Refetch the current access token given a refresh token.
58    async fn refetch_token(&self) -> ClientResult<Option<Token>>;
59
60    /// Re-authenticate the client automatically if it's configured to do so,
61    /// which uses the refresh token to obtain a new access token.
62    async fn auto_reauth(&self) -> ClientResult<()> {
63        if !self.get_config().token_refreshing {
64            return Ok(());
65        }
66
67        // NOTE: It's important to not leave the token locked, or else a
68        // deadlock when calling `refresh_token` will occur.
69        let should_reauth = self
70            .get_token()
71            .lock()
72            .await
73            .unwrap()
74            .as_ref()
75            .map_or(false, Token::is_expired);
76
77        if should_reauth {
78            self.refresh_token().await
79        } else {
80            Ok(())
81        }
82    }
83
84    /// Refreshes the current access token given a refresh token. The obtained
85    /// token will be saved internally.
86    async fn refresh_token(&self) -> ClientResult<()> {
87        let token = self.refetch_token().await?;
88        *self.get_token().lock().await.unwrap() = token;
89        self.write_token_cache().await
90    }
91
92    /// The headers required for authenticated requests to the API.
93    ///
94    /// Since this is accessed by authenticated requests always, it's where the
95    /// automatic reauthentication takes place, if enabled.
96    #[doc(hidden)]
97    async fn auth_headers(&self) -> ClientResult<Headers> {
98        self.auto_reauth().await?;
99
100        Ok(self
101            .get_token()
102            .lock()
103            .await
104            .unwrap()
105            .as_ref()
106            .ok_or(ClientError::InvalidToken)?
107            .auth_headers())
108    }
109
110    // HTTP-related methods for the Spotify client. They wrap up the basic HTTP
111    // client with its specific usage for endpoints or authentication.
112
113    /// Convenience method to send GET requests related to an endpoint in the
114    /// API.
115    #[doc(hidden)]
116    #[inline]
117    async fn api_get(&self, url: &str, payload: &Query<'_>) -> ClientResult<String> {
118        let url = self.api_url(url);
119        let headers = self.auth_headers().await?;
120        Ok(self.get_http().get(&url, Some(&headers), payload).await?)
121    }
122
123    /// Convenience method to send POST requests related to an endpoint in the
124    /// API.
125    #[doc(hidden)]
126    #[inline]
127    async fn api_post(&self, url: &str, payload: &Value) -> ClientResult<String> {
128        let url = self.api_url(url);
129        let headers = self.auth_headers().await?;
130        Ok(self.get_http().post(&url, Some(&headers), payload).await?)
131    }
132
133    /// Convenience method to send PUT requests related to an endpoint in the
134    /// API.
135    #[doc(hidden)]
136    #[inline]
137    async fn api_put(&self, url: &str, payload: &Value) -> ClientResult<String> {
138        let url = self.api_url(url);
139        let headers = self.auth_headers().await?;
140        Ok(self.get_http().put(&url, Some(&headers), payload).await?)
141    }
142
143    /// Convenience method to send DELETE requests related to an endpoint in the
144    /// API.
145    #[doc(hidden)]
146    #[inline]
147    async fn api_delete(&self, url: &str, payload: &Value) -> ClientResult<String> {
148        let url = self.api_url(url);
149        let headers = self.auth_headers().await?;
150        Ok(self
151            .get_http()
152            .delete(&url, Some(&headers), payload)
153            .await?)
154    }
155
156    /// Convenience method to send POST requests related to the authentication
157    /// process.
158    #[doc(hidden)]
159    #[inline]
160    async fn auth_post(
161        &self,
162        url: &str,
163        headers: Option<&Headers>,
164        payload: &Form<'_>,
165    ) -> ClientResult<String> {
166        let url = self.auth_url(url);
167        Ok(self.get_http().post_form(&url, headers, payload).await?)
168    }
169
170    /// Updates the cache file at the internal cache path.
171    ///
172    /// This should be used whenever it's possible to, even if the cached token
173    /// isn't configured, because this will already check `Config::token_cached`
174    /// and do nothing in that case already.
175    async fn write_token_cache(&self) -> ClientResult<()> {
176        if !self.get_config().token_cached {
177            log::info!("Token cache write ignored (not configured)");
178            return Ok(());
179        }
180
181        log::info!("Writing token cache");
182        if let Some(tok) = self.get_token().lock().await.unwrap().as_ref() {
183            tok.write_cache(&self.get_config().cache_path)?;
184        }
185
186        Ok(())
187    }
188
189    /// Sends a request to Spotify for an access token.
190    async fn fetch_access_token(
191        &self,
192        payload: &Form<'_>,
193        headers: Option<&Headers>,
194    ) -> ClientResult<Token> {
195        let response = self.auth_post(auth_urls::TOKEN, headers, payload).await?;
196
197        let mut tok = serde_json::from_str::<Token>(&response)?;
198        tok.expires_at = Utc::now().checked_add_signed(tok.expires_in);
199        Ok(tok)
200    }
201
202    /// Returns a single track given the track's ID, URI or URL.
203    ///
204    /// Parameters:
205    /// - track_id - a spotify URI, URL or ID
206    ///
207    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-track)
208    async fn track(
209        &self,
210        track_id: TrackId<'_>,
211        market: Option<Market>,
212    ) -> ClientResult<FullTrack> {
213        let params = build_map([("market", market.map(Into::into))]);
214
215        let url = format!("tracks/{}", track_id.id());
216        let result = self.api_get(&url, &params).await?;
217        convert_result(&result)
218    }
219
220    /// Returns a list of tracks given a list of track IDs, URIs, or URLs.
221    ///
222    /// Parameters:
223    /// - track_ids - a list of spotify URIs, URLs or IDs
224    /// - market - an ISO 3166-1 alpha-2 country code or the string from_token.
225    ///
226    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-several-tracks)
227    async fn tracks<'a>(
228        &self,
229        track_ids: impl IntoIterator<Item = TrackId<'a>> + Send + 'a,
230        market: Option<Market>,
231    ) -> ClientResult<Vec<FullTrack>> {
232        let ids = join_ids(track_ids);
233        let params = build_map([("market", market.map(Into::into))]);
234
235        let url = format!("tracks/?ids={ids}");
236        let result = self.api_get(&url, &params).await?;
237        convert_result::<FullTracks>(&result).map(|x| x.tracks)
238    }
239
240    /// Returns a single artist given the artist's ID, URI or URL.
241    ///
242    /// Parameters:
243    /// - artist_id - an artist ID, URI or URL
244    ///
245    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artist)
246    async fn artist(&self, artist_id: ArtistId<'_>) -> ClientResult<FullArtist> {
247        let url = format!("artists/{}", artist_id.id());
248        let result = self.api_get(&url, &Query::new()).await?;
249        convert_result(&result)
250    }
251
252    /// Returns a list of artists given the artist IDs, URIs, or URLs.
253    ///
254    /// Parameters:
255    /// - artist_ids - a list of artist IDs, URIs or URLs
256    ///
257    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-artists)
258    async fn artists<'a>(
259        &self,
260        artist_ids: impl IntoIterator<Item = ArtistId<'a>> + Send + 'a,
261    ) -> ClientResult<Vec<FullArtist>> {
262        let ids = join_ids(artist_ids);
263        let url = format!("artists/?ids={ids}");
264        let result = self.api_get(&url, &Query::new()).await?;
265
266        convert_result::<FullArtists>(&result).map(|x| x.artists)
267    }
268
269    /// Get Spotify catalog information about an artist's albums.
270    ///
271    /// Parameters:
272    /// - artist_id - the artist ID, URI or URL
273    /// - include_groups -  a list of album type like 'album', 'single' that will be used to filter response. if not supplied, all album types will be returned.
274    /// - market - limit the response to one particular country.
275    /// - limit  - the number of albums to return
276    /// - offset - the index of the first album to return
277    ///
278    /// See [`Self::artist_albums_manual`] for a manually paginated version of
279    /// this.
280    ///
281    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artists-albums)
282    fn artist_albums<'a>(
283        &'a self,
284        artist_id: ArtistId<'a>,
285        include_groups: impl IntoIterator<Item = AlbumType> + Send + Copy + 'a,
286        market: Option<Market>,
287    ) -> Paginator<'_, ClientResult<SimplifiedAlbum>> {
288        paginate_with_ctx(
289            (self, artist_id),
290            move |(slf, artist_id), limit, offset| {
291                slf.artist_albums_manual(
292                    artist_id.as_ref(),
293                    include_groups,
294                    market,
295                    Some(limit),
296                    Some(offset),
297                )
298            },
299            self.get_config().pagination_chunks,
300        )
301    }
302
303    /// The manually paginated version of [`Self::artist_albums`].
304    async fn artist_albums_manual<'a>(
305        &self,
306        artist_id: ArtistId<'_>,
307        include_groups: impl IntoIterator<Item = AlbumType> + Send + 'a,
308        market: Option<Market>,
309        limit: Option<u32>,
310        offset: Option<u32>,
311    ) -> ClientResult<Page<SimplifiedAlbum>> {
312        let limit = limit.map(|x| x.to_string());
313        let offset = offset.map(|x| x.to_string());
314        let include_groups_vec = include_groups
315            .into_iter()
316            .map(|t| t.into())
317            .collect::<Vec<&'static str>>();
318        let include_groups_opt = include_groups_vec
319            .is_empty()
320            .not()
321            .then_some(include_groups_vec)
322            .map(|t| t.join(","));
323
324        let params = build_map([
325            ("include_groups", include_groups_opt.as_deref()),
326            ("market", market.map(Into::into)),
327            ("limit", limit.as_deref()),
328            ("offset", offset.as_deref()),
329        ]);
330
331        let url = format!("artists/{}/albums", artist_id.id());
332        let result = self.api_get(&url, &params).await?;
333        convert_result(&result)
334    }
335
336    /// Get Spotify catalog information about an artist's top 10 tracks by
337    /// country.
338    ///
339    /// Parameters:
340    /// - artist_id - the artist ID, URI or URL
341    /// - market - limit the response to one particular country.
342    ///
343    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artists-top-tracks)
344    async fn artist_top_tracks(
345        &self,
346        artist_id: ArtistId<'_>,
347        market: Option<Market>,
348    ) -> ClientResult<Vec<FullTrack>> {
349        let params = build_map([("market", market.map(Into::into))]);
350
351        let url = format!("artists/{}/top-tracks", artist_id.id());
352        let result = self.api_get(&url, &params).await?;
353        convert_result::<FullTracks>(&result).map(|x| x.tracks)
354    }
355
356    /// Get Spotify catalog information about artists similar to an identified
357    /// artist. Similarity is based on analysis of the Spotify community's
358    /// listening history.
359    ///
360    /// Parameters:
361    /// - artist_id - the artist ID, URI or URL
362    ///
363    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-artists-related-artists)
364    async fn artist_related_artists(
365        &self,
366        artist_id: ArtistId<'_>,
367    ) -> ClientResult<Vec<FullArtist>> {
368        let url = format!("artists/{}/related-artists", artist_id.id());
369        let result = self.api_get(&url, &Query::new()).await?;
370        convert_result::<FullArtists>(&result).map(|x| x.artists)
371    }
372
373    /// Returns a single album given the album's ID, URIs or URL.
374    ///
375    /// Parameters:
376    /// - album_id - the album ID, URI or URL
377    ///
378    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-album)
379    async fn album(
380        &self,
381        album_id: AlbumId<'_>,
382        market: Option<Market>,
383    ) -> ClientResult<FullAlbum> {
384        let params = build_map([("market", market.map(Into::into))]);
385
386        let url = format!("albums/{}", album_id.id());
387        let result = self.api_get(&url, &params).await?;
388        convert_result(&result)
389    }
390
391    /// Returns a list of albums given the album IDs, URIs, or URLs.
392    ///
393    /// Parameters:
394    /// - albums_ids - a list of album IDs, URIs or URLs
395    ///
396    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-albums)
397    async fn albums<'a>(
398        &self,
399        album_ids: impl IntoIterator<Item = AlbumId<'a>> + Send + 'a,
400        market: Option<Market>,
401    ) -> ClientResult<Vec<FullAlbum>> {
402        let params = build_map([("market", market.map(Into::into))]);
403
404        let ids = join_ids(album_ids);
405        let url = format!("albums/?ids={ids}");
406        let result = self.api_get(&url, &params).await?;
407        convert_result::<FullAlbums>(&result).map(|x| x.albums)
408    }
409
410    /// Search for an Item. Get Spotify catalog information about artists,
411    /// albums, tracks or playlists that match a keyword string.
412    ///
413    /// Parameters:
414    /// - q - the search query
415    /// - limit  - the number of items to return
416    /// - offset - the index of the first item to return
417    /// - type - the type of item to return. One of 'artist', 'album', 'track',
418    ///  'playlist', 'show' or 'episode'
419    /// - market - An ISO 3166-1 alpha-2 country code or the string from_token.
420    /// - include_external: Optional.Possible values: audio. If
421    ///   include_external=audio is specified the response will include any
422    ///   relevant audio content that is hosted externally.  
423    ///
424    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/search)
425    async fn search(
426        &self,
427        q: &str,
428        _type: SearchType,
429        market: Option<Market>,
430        include_external: Option<IncludeExternal>,
431        limit: Option<u32>,
432        offset: Option<u32>,
433    ) -> ClientResult<SearchResult> {
434        let limit = limit.map(|s| s.to_string());
435        let offset = offset.map(|s| s.to_string());
436        let params = build_map([
437            ("q", Some(q)),
438            ("type", Some(_type.into())),
439            ("market", market.map(Into::into)),
440            ("include_external", include_external.map(Into::into)),
441            ("limit", limit.as_deref()),
442            ("offset", offset.as_deref()),
443        ]);
444
445        let result = self.api_get("search", &params).await?;
446        convert_result(&result)
447    }
448
449    /// Get Spotify catalog information about an album's tracks.
450    ///
451    /// Parameters:
452    /// - album_id - the album ID, URI or URL
453    /// - limit  - the number of items to return
454    /// - offset - the index of the first item to return
455    ///
456    /// See [`Self::album_track_manual`] for a manually paginated version of
457    /// this.
458    ///
459    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-albums-tracks)
460    fn album_track<'a>(
461        &'a self,
462        album_id: AlbumId<'a>,
463        market: Option<Market>,
464    ) -> Paginator<'_, ClientResult<SimplifiedTrack>> {
465        paginate_with_ctx(
466            (self, album_id),
467            move |(slf, album_id), limit, offset| {
468                slf.album_track_manual(album_id.as_ref(), market, Some(limit), Some(offset))
469            },
470            self.get_config().pagination_chunks,
471        )
472    }
473
474    /// The manually paginated version of [`Self::album_track`].
475    async fn album_track_manual(
476        &self,
477        album_id: AlbumId<'_>,
478        market: Option<Market>,
479        limit: Option<u32>,
480        offset: Option<u32>,
481    ) -> ClientResult<Page<SimplifiedTrack>> {
482        let limit = limit.map(|s| s.to_string());
483        let offset = offset.map(|s| s.to_string());
484        let params = build_map([
485            ("limit", limit.as_deref()),
486            ("offset", offset.as_deref()),
487            ("market", market.map(Into::into)),
488        ]);
489
490        let url = format!("albums/{}/tracks", album_id.id());
491        let result = self.api_get(&url, &params).await?;
492        convert_result(&result)
493    }
494
495    /// Gets basic profile information about a Spotify User.
496    ///
497    /// Parameters:
498    /// - user - the id of the usr
499    ///
500    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-users-profile)
501    async fn user(&self, user_id: UserId<'_>) -> ClientResult<PublicUser> {
502        let url = format!("users/{}", user_id.id());
503        let result = self.api_get(&url, &Query::new()).await?;
504        convert_result(&result)
505    }
506
507    /// Get full details about Spotify playlist.
508    ///
509    /// Parameters:
510    /// - playlist_id - the id of the playlist
511    /// - market - an ISO 3166-1 alpha-2 country code or the string from_token.
512    ///
513    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-playlist)
514    async fn playlist(
515        &self,
516        playlist_id: PlaylistId<'_>,
517        fields: Option<&str>,
518        market: Option<Market>,
519    ) -> ClientResult<FullPlaylist> {
520        let params = build_map([("fields", fields), ("market", market.map(Into::into))]);
521
522        let url = format!("playlists/{}", playlist_id.id());
523        let result = self.api_get(&url, &params).await?;
524        convert_result(&result)
525    }
526
527    /// Gets playlist of a user.
528    ///
529    /// Parameters:
530    /// - user_id - the id of the user
531    /// - playlist_id - the id of the playlist
532    /// - fields - which fields to return
533    ///
534    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-list-users-playlists)
535    async fn user_playlist(
536        &self,
537        user_id: UserId<'_>,
538        playlist_id: Option<PlaylistId<'_>>,
539        fields: Option<&str>,
540    ) -> ClientResult<FullPlaylist> {
541        let params = build_map([("fields", fields)]);
542
543        let url = match playlist_id {
544            Some(playlist_id) => format!("users/{}/playlists/{}", user_id.id(), playlist_id.id()),
545            None => format!("users/{}/starred", user_id.id()),
546        };
547        let result = self.api_get(&url, &params).await?;
548        convert_result(&result)
549    }
550
551    /// Check to see if the given users are following the given playlist.
552    ///
553    /// Parameters:
554    /// - playlist_id - the id of the playlist
555    /// - user_ids - the ids of the users that you want to check to see if they
556    ///   follow the playlist. Maximum: 5 ids.
557    ///
558    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/check-if-user-follows-playlist)
559    async fn playlist_check_follow(
560        &self,
561        playlist_id: PlaylistId<'_>,
562        user_ids: &[UserId<'_>],
563    ) -> ClientResult<Vec<bool>> {
564        debug_assert!(
565            user_ids.len() <= 5,
566            "The maximum length of user ids is limited to 5 :-)"
567        );
568        let url = format!(
569            "playlists/{}/followers/contains?ids={}",
570            playlist_id.id(),
571            user_ids.iter().map(Id::id).collect::<Vec<_>>().join(","),
572        );
573        let result = self.api_get(&url, &Query::new()).await?;
574        convert_result(&result)
575    }
576
577    /// Get Spotify catalog information for a single show identified by its unique Spotify ID.
578    ///
579    /// Path Parameters:
580    /// - id: The Spotify ID for the show.
581    ///
582    /// Query Parameters
583    /// - market(Optional): An ISO 3166-1 alpha-2 country code or the string from_token.
584    ///
585    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-a-show)
586    async fn get_a_show(&self, id: ShowId<'_>, market: Option<Market>) -> ClientResult<FullShow> {
587        let params = build_map([("market", market.map(Into::into))]);
588
589        let url = format!("shows/{}", id.id());
590        let result = self.api_get(&url, &params).await?;
591        convert_result(&result)
592    }
593
594    /// Get Spotify catalog information for multiple shows based on their
595    /// Spotify IDs.
596    ///
597    /// Query Parameters
598    /// - ids(Required) A comma-separated list of the Spotify IDs for the shows. Maximum: 50 IDs.
599    /// - market(Optional) An ISO 3166-1 alpha-2 country code or the string from_token.
600    ///
601    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-shows)
602    async fn get_several_shows<'a>(
603        &self,
604        ids: impl IntoIterator<Item = ShowId<'a>> + Send + 'a,
605        market: Option<Market>,
606    ) -> ClientResult<Vec<SimplifiedShow>> {
607        let ids = join_ids(ids);
608        let params = build_map([("ids", Some(&ids)), ("market", market.map(Into::into))]);
609
610        let result = self.api_get("shows", &params).await?;
611        convert_result::<SeversalSimplifiedShows>(&result).map(|x| x.shows)
612    }
613
614    /// Get Spotify catalog information about an show’s episodes. Optional
615    /// parameters can be used to limit the number of episodes returned.
616    ///
617    /// Path Parameters
618    /// - id: The Spotify ID for the show.
619    ///
620    /// Query Parameters
621    /// - limit: Optional. The maximum number of episodes to return. Default: 20. Minimum: 1. Maximum: 50.
622    /// - offset: Optional. The index of the first episode to return. Default: 0 (the first object). Use with limit to get the next set of episodes.
623    /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token.
624    ///
625    /// See [`Self::get_shows_episodes_manual`] for a manually paginated version
626    /// of this.
627    ///
628    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-a-shows-episodes)
629    fn get_shows_episodes<'a>(
630        &'a self,
631        id: ShowId<'a>,
632        market: Option<Market>,
633    ) -> Paginator<'_, ClientResult<SimplifiedEpisode>> {
634        paginate_with_ctx(
635            (self, id),
636            move |(slf, id), limit, offset| {
637                slf.get_shows_episodes_manual(id.as_ref(), market, Some(limit), Some(offset))
638            },
639            self.get_config().pagination_chunks,
640        )
641    }
642
643    /// The manually paginated version of [`Self::get_shows_episodes`].
644    async fn get_shows_episodes_manual(
645        &self,
646        id: ShowId<'_>,
647        market: Option<Market>,
648        limit: Option<u32>,
649        offset: Option<u32>,
650    ) -> ClientResult<Page<SimplifiedEpisode>> {
651        let limit = limit.map(|x| x.to_string());
652        let offset = offset.map(|x| x.to_string());
653        let params = build_map([
654            ("market", market.map(Into::into)),
655            ("limit", limit.as_deref()),
656            ("offset", offset.as_deref()),
657        ]);
658
659        let url = format!("shows/{}/episodes", id.id());
660        let result = self.api_get(&url, &params).await?;
661        convert_result(&result)
662    }
663
664    /// Get Spotify catalog information for a single episode identified by its unique Spotify ID.
665    ///
666    /// Path Parameters
667    /// - id: The Spotify ID for the episode.
668    ///
669    /// Query Parameters
670    /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token.
671    ///
672    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-an-episode)
673    async fn get_an_episode(
674        &self,
675        id: EpisodeId<'_>,
676        market: Option<Market>,
677    ) -> ClientResult<FullEpisode> {
678        let url = format!("episodes/{}", id.id());
679        let params = build_map([("market", market.map(Into::into))]);
680
681        let result = self.api_get(&url, &params).await?;
682        convert_result(&result)
683    }
684
685    /// Get Spotify catalog information for multiple episodes based on their Spotify IDs.
686    ///
687    /// Query Parameters
688    /// - ids: Required. A comma-separated list of the Spotify IDs for the episodes. Maximum: 50 IDs.
689    /// - market: Optional. An ISO 3166-1 alpha-2 country code or the string from_token.
690    ///
691    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-multiple-episodes)
692    async fn get_several_episodes<'a>(
693        &self,
694        ids: impl IntoIterator<Item = EpisodeId<'a>> + Send + 'a,
695        market: Option<Market>,
696    ) -> ClientResult<Vec<FullEpisode>> {
697        let ids = join_ids(ids);
698        let params = build_map([("ids", Some(&ids)), ("market", market.map(Into::into))]);
699
700        let result = self.api_get("episodes", &params).await?;
701        convert_result::<EpisodesPayload>(&result).map(|x| x.episodes)
702    }
703
704    /// Get audio features for a track
705    ///
706    /// Parameters:
707    /// - track - track URI, URL or ID
708    ///
709    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-audio-features)
710    async fn track_features(&self, track_id: TrackId<'_>) -> ClientResult<AudioFeatures> {
711        let url = format!("audio-features/{}", track_id.id());
712        let result = self.api_get(&url, &Query::new()).await?;
713        convert_result(&result)
714    }
715
716    /// Get Audio Features for Several Tracks
717    ///
718    /// Parameters:
719    /// - tracks a list of track URIs, URLs or IDs
720    ///
721    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-several-audio-features)
722    async fn tracks_features<'a>(
723        &self,
724        track_ids: impl IntoIterator<Item = TrackId<'a>> + Send + 'a,
725    ) -> ClientResult<Option<Vec<AudioFeatures>>> {
726        let url = format!("audio-features/?ids={}", join_ids(track_ids));
727
728        let result = self.api_get(&url, &Query::new()).await?;
729        if result.is_empty() {
730            Ok(None)
731        } else if let Some(payload) = convert_result::<Option<AudioFeaturesPayload>>(&result)? {
732            let audio_features = payload.audio_features.into_iter().flatten().collect();
733            Ok(Some(audio_features))
734        } else {
735            Ok(None)
736        }
737    }
738
739    /// Get Audio Analysis for a Track
740    ///
741    /// Parameters:
742    /// - track_id - a track URI, URL or ID
743    ///
744    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-audio-analysis)
745    async fn track_analysis(&self, track_id: TrackId<'_>) -> ClientResult<AudioAnalysis> {
746        let url = format!("audio-analysis/{}", track_id.id());
747        let result = self.api_get(&url, &Query::new()).await?;
748        convert_result(&result)
749    }
750
751    /// Get a list of new album releases featured in Spotify
752    ///
753    /// Parameters:
754    /// - country - An ISO 3166-1 alpha-2 country code or string from_token.
755    /// - locale - The desired language, consisting of an ISO 639 language code
756    ///   and an ISO 3166-1 alpha-2 country code, joined by an underscore.
757    /// - limit - The maximum number of items to return. Default: 20.
758    ///   Minimum: 1. Maximum: 50
759    /// - offset - The index of the first item to return. Default: 0 (the first
760    ///   object). Use with limit to get the next set of items.
761    ///
762    /// See [`Self::categories_manual`] for a manually paginated version of
763    /// this.
764    ///
765    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-categories)
766    fn categories<'a>(
767        &'a self,
768        locale: Option<&'a str>,
769        country: Option<Market>,
770    ) -> Paginator<'_, ClientResult<Category>> {
771        paginate(
772            move |limit, offset| self.categories_manual(locale, country, Some(limit), Some(offset)),
773            self.get_config().pagination_chunks,
774        )
775    }
776
777    /// The manually paginated version of [`Self::categories`].
778    async fn categories_manual(
779        &self,
780        locale: Option<&str>,
781        country: Option<Market>,
782        limit: Option<u32>,
783        offset: Option<u32>,
784    ) -> ClientResult<Page<Category>> {
785        let limit = limit.map(|x| x.to_string());
786        let offset = offset.map(|x| x.to_string());
787        let params = build_map([
788            ("locale", locale),
789            ("country", country.map(Into::into)),
790            ("limit", limit.as_deref()),
791            ("offset", offset.as_deref()),
792        ]);
793        let result = self.api_get("browse/categories", &params).await?;
794        convert_result::<PageCategory>(&result).map(|x| x.categories)
795    }
796
797    /// Get a list of playlists in a category in Spotify
798    ///
799    /// Parameters:
800    /// - category_id - The category id to get playlists from.
801    /// - country - An ISO 3166-1 alpha-2 country code or the string from_token.
802    /// - limit - The maximum number of items to return. Default: 20.
803    ///   Minimum: 1. Maximum: 50
804    /// - offset - The index of the first item to return. Default: 0 (the first
805    ///   object). Use with limit to get the next set of items.
806    ///
807    /// See [`Self::category_playlists_manual`] for a manually paginated version
808    /// of this.
809    ///
810    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-a-categories-playlists)
811    fn category_playlists<'a>(
812        &'a self,
813        category_id: &'a str,
814        country: Option<Market>,
815    ) -> Paginator<'_, ClientResult<SimplifiedPlaylist>> {
816        paginate(
817            move |limit, offset| {
818                self.category_playlists_manual(category_id, country, Some(limit), Some(offset))
819            },
820            self.get_config().pagination_chunks,
821        )
822    }
823
824    /// The manually paginated version of [`Self::category_playlists`].
825    async fn category_playlists_manual(
826        &self,
827        category_id: &str,
828        country: Option<Market>,
829        limit: Option<u32>,
830        offset: Option<u32>,
831    ) -> ClientResult<Page<SimplifiedPlaylist>> {
832        let limit = limit.map(|x| x.to_string());
833        let offset = offset.map(|x| x.to_string());
834        let params = build_map([
835            ("country", country.map(Into::into)),
836            ("limit", limit.as_deref()),
837            ("offset", offset.as_deref()),
838        ]);
839
840        let url = format!("browse/categories/{category_id}/playlists");
841        let result = self.api_get(&url, &params).await?;
842        convert_result::<CategoryPlaylists>(&result).map(|x| x.playlists)
843    }
844
845    /// Get a list of Spotify featured playlists.
846    ///
847    /// Parameters:
848    /// - locale - The desired language, consisting of a lowercase ISO 639
849    ///   language code and an uppercase ISO 3166-1 alpha-2 country code,
850    ///   joined by an underscore.
851    /// - country - An ISO 3166-1 alpha-2 country code or the string from_token.
852    /// - timestamp - A timestamp in ISO 8601 format: yyyy-MM-ddTHH:mm:ss. Use
853    ///   this parameter to specify the user's local time to get results
854    ///   tailored for that specific date and time in the day
855    /// - limit - The maximum number of items to return. Default: 20.
856    ///   Minimum: 1. Maximum: 50
857    /// - offset - The index of the first item to return. Default: 0
858    ///   (the first object). Use with limit to get the next set of
859    ///   items.
860    ///
861    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-featured-playlists)
862    async fn featured_playlists(
863        &self,
864        locale: Option<&str>,
865        country: Option<Market>,
866        timestamp: Option<chrono::DateTime<chrono::Utc>>,
867        limit: Option<u32>,
868        offset: Option<u32>,
869    ) -> ClientResult<FeaturedPlaylists> {
870        let limit = limit.map(|x| x.to_string());
871        let offset = offset.map(|x| x.to_string());
872        let timestamp = timestamp.map(|x| x.to_rfc3339());
873        let params = build_map([
874            ("locale", locale),
875            ("country", country.map(Into::into)),
876            ("timestamp", timestamp.as_deref()),
877            ("limit", limit.as_deref()),
878            ("offset", offset.as_deref()),
879        ]);
880
881        let result = self.api_get("browse/featured-playlists", &params).await?;
882        convert_result(&result)
883    }
884
885    /// Get a list of new album releases featured in Spotify.
886    ///
887    /// Parameters:
888    /// - country - An ISO 3166-1 alpha-2 country code or string from_token.
889    /// - limit - The maximum number of items to return. Default: 20.
890    ///   Minimum: 1. Maximum: 50
891    /// - offset - The index of the first item to return. Default: 0 (the first
892    ///   object). Use with limit to get the next set of items.
893    ///
894    /// See [`Self::new_releases_manual`] for a manually paginated version of
895    /// this.
896    ///
897    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-new-releases)
898    fn new_releases(
899        &self,
900        country: Option<Market>,
901    ) -> Paginator<'_, ClientResult<SimplifiedAlbum>> {
902        paginate(
903            move |limit, offset| self.new_releases_manual(country, Some(limit), Some(offset)),
904            self.get_config().pagination_chunks,
905        )
906    }
907
908    /// The manually paginated version of [`Self::new_releases`].
909    async fn new_releases_manual(
910        &self,
911        country: Option<Market>,
912        limit: Option<u32>,
913        offset: Option<u32>,
914    ) -> ClientResult<Page<SimplifiedAlbum>> {
915        let limit = limit.map(|x| x.to_string());
916        let offset = offset.map(|x| x.to_string());
917        let params = build_map([
918            ("country", country.map(Into::into)),
919            ("limit", limit.as_deref()),
920            ("offset", offset.as_deref()),
921        ]);
922
923        let result = self.api_get("browse/new-releases", &params).await?;
924        convert_result::<PageSimplifiedAlbums>(&result).map(|x| x.albums)
925    }
926
927    /// Get Recommendations Based on Seeds
928    ///
929    /// Parameters:
930    /// - attributes - restrictions on attributes for the selected tracks, such
931    ///   as `min_acousticness` or `target_duration_ms`.
932    /// - seed_artists - a list of artist IDs, URIs or URLs
933    /// - seed_tracks - a list of artist IDs, URIs or URLs
934    /// - seed_genres - a list of genre names. Available genres for
935    /// - market - An ISO 3166-1 alpha-2 country code or the string from_token.
936    ///   If provided, all results will be playable in this country.
937    /// - limit - The maximum number of items to return. Default: 20.
938    ///   Minimum: 1. Maximum: 100
939    /// - `min/max/target_<attribute>` - For the tuneable track attributes
940    ///   listed in the documentation, these values provide filters and
941    ///   targeting on results.
942    ///
943    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-recommendations)
944    async fn recommendations<'a>(
945        &self,
946        attributes: impl IntoIterator<Item = RecommendationsAttribute> + Send + 'a,
947        seed_artists: Option<impl IntoIterator<Item = ArtistId<'a>> + Send + 'a>,
948        seed_genres: Option<impl IntoIterator<Item = &'a str> + Send + 'a>,
949        seed_tracks: Option<impl IntoIterator<Item = TrackId<'a>> + Send + 'a>,
950        market: Option<Market>,
951        limit: Option<u32>,
952    ) -> ClientResult<Recommendations> {
953        let seed_artists = seed_artists.map(join_ids);
954        let seed_genres = seed_genres.map(|x| x.into_iter().collect::<Vec<_>>().join(","));
955        let seed_tracks = seed_tracks.map(join_ids);
956        let limit = limit.map(|x| x.to_string());
957        let mut params = build_map([
958            ("seed_artists", seed_artists.as_deref()),
959            ("seed_genres", seed_genres.as_deref()),
960            ("seed_tracks", seed_tracks.as_deref()),
961            ("market", market.map(Into::into)),
962            ("limit", limit.as_deref()),
963        ]);
964
965        // First converting the attributes into owned `String`s
966        let owned_attributes = attributes
967            .into_iter()
968            .map(|attr| (<&str>::from(attr).to_owned(), attr.value_string()))
969            .collect::<HashMap<_, _>>();
970        // Afterwards converting the values into `&str`s; otherwise they
971        // wouldn't live long enough
972        let borrowed_attributes = owned_attributes
973            .iter()
974            .map(|(key, value)| (key.as_str(), value.as_str()));
975        // And finally adding all of them to the payload
976        params.extend(borrowed_attributes);
977
978        let result = self.api_get("recommendations", &params).await?;
979        convert_result(&result)
980    }
981
982    /// Get full details of the items of a playlist owned by a user.
983    ///
984    /// Parameters:
985    /// - playlist_id - the id of the playlist
986    /// - fields - which fields to return
987    /// - limit - the maximum number of tracks to return
988    /// - offset - the index of the first track to return
989    /// - market - an ISO 3166-1 alpha-2 country code or the string from_token.
990    ///
991    /// See [`Self::playlist_items_manual`] for a manually paginated version of
992    /// this.
993    ///
994    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-playlists-tracks)
995    fn playlist_items<'a>(
996        &'a self,
997        playlist_id: PlaylistId<'a>,
998        fields: Option<&'a str>,
999        market: Option<Market>,
1000    ) -> Paginator<'_, ClientResult<PlaylistItem>> {
1001        paginate_with_ctx(
1002            (self, playlist_id, fields),
1003            move |(slf, playlist_id, fields), limit, offset| {
1004                slf.playlist_items_manual(
1005                    playlist_id.as_ref(),
1006                    *fields,
1007                    market,
1008                    Some(limit),
1009                    Some(offset),
1010                )
1011            },
1012            self.get_config().pagination_chunks,
1013        )
1014    }
1015
1016    /// The manually paginated version of [`Self::playlist_items`].
1017    async fn playlist_items_manual(
1018        &self,
1019        playlist_id: PlaylistId<'_>,
1020        fields: Option<&str>,
1021        market: Option<Market>,
1022        limit: Option<u32>,
1023        offset: Option<u32>,
1024    ) -> ClientResult<Page<PlaylistItem>> {
1025        let limit = limit.map(|s| s.to_string());
1026        let offset = offset.map(|s| s.to_string());
1027        let params = build_map([
1028            ("fields", fields),
1029            ("market", market.map(Into::into)),
1030            ("limit", limit.as_deref()),
1031            ("offset", offset.as_deref()),
1032        ]);
1033
1034        let url = format!("playlists/{}/tracks", playlist_id.id());
1035        let result = self.api_get(&url, &params).await?;
1036        convert_result(&result)
1037    }
1038
1039    /// Gets playlists of a user.
1040    ///
1041    /// Parameters:
1042    /// - user_id - the id of the usr
1043    /// - limit  - the number of items to return
1044    /// - offset - the index of the first item to return
1045    ///
1046    /// See [`Self::user_playlists_manual`] for a manually paginated version of
1047    /// this.
1048    ///
1049    /// [Reference](https://developer.spotify.com/documentation/web-api/reference/#/operations/get-list-users-playlists)
1050    fn user_playlists<'a>(
1051        &'a self,
1052        user_id: UserId<'a>,
1053    ) -> Paginator<'_, ClientResult<SimplifiedPlaylist>> {
1054        paginate_with_ctx(
1055            (self, user_id),
1056            move |(slf, user_id), limit, offset| {
1057                slf.user_playlists_manual(user_id.as_ref(), Some(limit), Some(offset))
1058            },
1059            self.get_config().pagination_chunks,
1060        )
1061    }
1062
1063    /// The manually paginated version of [`Self::user_playlists`].
1064    async fn user_playlists_manual(
1065        &self,
1066        user_id: UserId<'_>,
1067        limit: Option<u32>,
1068        offset: Option<u32>,
1069    ) -> ClientResult<Page<SimplifiedPlaylist>> {
1070        let limit = limit.map(|s| s.to_string());
1071        let offset = offset.map(|s| s.to_string());
1072        let params = build_map([("limit", limit.as_deref()), ("offset", offset.as_deref())]);
1073
1074        let url = format!("users/{}/playlists", user_id.id());
1075        let result = self.api_get(&url, &params).await?;
1076        convert_result(&result)
1077    }
1078}