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#[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}