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