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