spotify_cli/spotify/
search.rs

1use anyhow::bail;
2use reqwest::blocking::Client as HttpClient;
3use serde::Deserialize;
4
5use crate::domain::search::{SearchItem, SearchResults, SearchType};
6use crate::error::Result;
7use crate::spotify::auth::AuthService;
8use crate::spotify::base::api_base;
9use crate::spotify::error::format_api_error;
10
11
12/// Spotify search API client.
13#[derive(Debug, Clone)]
14pub struct SearchClient {
15    http: HttpClient,
16    auth: AuthService,
17}
18
19impl SearchClient {
20    pub fn new(http: HttpClient, auth: AuthService) -> Self {
21        Self { http, auth }
22    }
23
24    pub fn search(
25        &self,
26        query: &str,
27        kind: SearchType,
28        limit: u32,
29        market_from_token: bool,
30    ) -> Result<SearchResults> {
31        if kind == SearchType::All {
32            let mut items = Vec::new();
33            let kinds = [
34                SearchType::Track,
35                SearchType::Album,
36                SearchType::Artist,
37                SearchType::Playlist,
38            ];
39            for kind in kinds {
40                let results = self.search(query, kind, limit, market_from_token)?;
41                items.extend(results.items);
42            }
43            return Ok(SearchResults {
44                kind: SearchType::All,
45                items,
46            });
47        }
48
49        let token = self.auth.token()?;
50        let kind_param = search_type_param(kind);
51        let mut url = format!(
52            "{}/search?q={}&type={}&limit={}",
53            api_base(),
54            urlencoding::encode(query),
55            kind_param,
56            limit
57        );
58
59        if market_from_token {
60            url.push_str("&market=from_token");
61        }
62
63        let response = self
64            .http
65            .get(url)
66            .bearer_auth(token.access_token)
67            .send()?;
68
69        if !response.status().is_success() {
70            let status = response.status();
71            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
72            bail!(format_api_error("spotify search failed", status, &body));
73        }
74
75        let payload: SearchResponse = response.json()?;
76        let items = match kind {
77            SearchType::Track => payload
78                .tracks
79                .map(|list| {
80                    list.items
81                        .into_iter()
82                        .filter_map(|item| item)
83                        .map(|item| SearchItem {
84                            id: item.id,
85                            name: item.name,
86                            uri: item.uri,
87                            kind: SearchType::Track,
88                            artists: item.artists.into_iter().map(|artist| artist.name).collect(),
89                            album: item.album.map(|album| album.name),
90                            duration_ms: item.duration_ms,
91                            owner: None,
92                            score: None,
93                        })
94                        .collect::<Vec<_>>()
95                })
96                .unwrap_or_default(),
97            SearchType::Album => payload
98                .albums
99                .map(|list| {
100                    list.items
101                        .into_iter()
102                        .filter_map(|item| item)
103                        .map(|item| SearchItem {
104                            id: item.id,
105                            name: item.name,
106                            uri: item.uri,
107                            kind: SearchType::Album,
108                            artists: item.artists.into_iter().map(|artist| artist.name).collect(),
109                            album: None,
110                            duration_ms: None,
111                            owner: None,
112                            score: None,
113                        })
114                        .collect::<Vec<_>>()
115                })
116                .unwrap_or_default(),
117            SearchType::Artist => payload
118                .artists
119                .map(|list| {
120                    list.items
121                        .into_iter()
122                        .filter_map(|item| item)
123                        .map(|item| SearchItem {
124                            id: item.id,
125                            name: item.name,
126                            uri: item.uri,
127                            kind: SearchType::Artist,
128                            artists: Vec::new(),
129                            album: None,
130                            duration_ms: None,
131                            owner: None,
132                            score: None,
133                        })
134                        .collect::<Vec<_>>()
135                })
136                .unwrap_or_default(),
137            SearchType::Playlist => payload
138                .playlists
139                .map(|list| {
140                    list.items
141                        .into_iter()
142                        .filter_map(|item| item)
143                        .map(|item| SearchItem {
144                            id: item.id,
145                            name: item.name,
146                            uri: item.uri,
147                            kind: SearchType::Playlist,
148                            artists: Vec::new(),
149                            album: None,
150                            duration_ms: None,
151                            owner: item.owner.and_then(|owner| owner.display_name),
152                            score: None,
153                        })
154                        .collect::<Vec<_>>()
155                })
156                .unwrap_or_default(),
157            SearchType::All => Vec::new(),
158        };
159
160        Ok(SearchResults { kind, items })
161    }
162
163    pub fn recently_played(&self, limit: u32) -> Result<Vec<SearchItem>> {
164        let token = self.auth.token()?;
165        let url = format!("{}/me/player/recently-played?limit={}", api_base(), limit);
166
167        let response = self
168            .http
169            .get(url)
170            .bearer_auth(token.access_token)
171            .send()?;
172
173        if !response.status().is_success() {
174            let status = response.status();
175            let body = response.text().unwrap_or_else(|_| "<no body>".to_string());
176            bail!(format_api_error("spotify recently played failed", status, &body));
177        }
178
179        let payload: RecentlyPlayedResponse = response.json()?;
180        Ok(payload
181            .items
182            .into_iter()
183            .filter_map(|item| item.track.map(map_track))
184            .collect())
185    }
186}
187
188fn search_type_param(kind: SearchType) -> &'static str {
189    match kind {
190        SearchType::All => "track,album,artist,playlist",
191        SearchType::Track => "track",
192        SearchType::Album => "album",
193        SearchType::Artist => "artist",
194        SearchType::Playlist => "playlist",
195    }
196}
197
198#[derive(Debug, Deserialize)]
199struct SearchResponse {
200    tracks: Option<ItemList<SpotifyTrack>>,
201    albums: Option<ItemList<SpotifyAlbum>>,
202    artists: Option<ItemList<SpotifyArtist>>,
203    playlists: Option<ItemList<SpotifyPlaylist>>,
204}
205
206#[derive(Debug, Deserialize)]
207struct ItemList<T> {
208    items: Vec<Option<T>>,
209}
210
211#[derive(Debug, Deserialize)]
212struct SpotifyTrack {
213    id: String,
214    name: String,
215    uri: String,
216    artists: Vec<SpotifyArtistRef>,
217    album: Option<SpotifyAlbumRef>,
218    duration_ms: Option<u32>,
219}
220
221#[derive(Debug, Deserialize)]
222struct SpotifyAlbum {
223    id: String,
224    name: String,
225    uri: String,
226    artists: Vec<SpotifyArtistRef>,
227}
228
229#[derive(Debug, Deserialize)]
230struct RecentlyPlayedResponse {
231    items: Vec<RecentlyPlayedItem>,
232}
233
234#[derive(Debug, Deserialize)]
235pub struct RecentlyPlayedItem {
236    track: Option<SpotifyTrack>,
237}
238
239fn map_track(item: SpotifyTrack) -> SearchItem {
240    SearchItem {
241        id: item.id,
242        name: item.name,
243        uri: item.uri,
244        kind: SearchType::Track,
245        artists: item.artists.into_iter().map(|artist| artist.name).collect(),
246        album: item.album.map(|album| album.name),
247        duration_ms: item.duration_ms,
248        owner: None,
249        score: None,
250    }
251}
252
253#[derive(Debug, Deserialize)]
254struct SpotifyAlbumRef {
255    name: String,
256}
257
258#[derive(Debug, Deserialize)]
259struct SpotifyArtist {
260    id: String,
261    name: String,
262    uri: String,
263}
264
265#[derive(Debug, Deserialize)]
266struct SpotifyPlaylist {
267    id: String,
268    name: String,
269    uri: String,
270    owner: Option<SpotifyOwner>,
271}
272
273#[derive(Debug, Deserialize)]
274struct SpotifyArtistRef {
275    name: String,
276}
277
278#[derive(Debug, Deserialize)]
279struct SpotifyOwner {
280    display_name: Option<String>,
281}