spotify_cli/endpoints/
search.rs

1use crate::http::api::SpotifyApi;
2use crate::http::client::HttpError;
3use crate::http::endpoints::Endpoint;
4use serde_json::Value;
5
6/// Search types supported by the Spotify API (singular form for API queries)
7pub const SEARCH_TYPES: &[&str] = &[
8    "track",
9    "artist",
10    "album",
11    "playlist",
12    "show",
13    "episode",
14    "audiobook",
15];
16
17/// Search result keys in API responses (plural form)
18pub const SEARCH_RESULT_KEYS: &[&str] = &[
19    "tracks",
20    "artists",
21    "albums",
22    "playlists",
23    "shows",
24    "episodes",
25    "audiobooks",
26];
27
28/// Search the Spotify catalog
29///
30/// # Arguments
31/// * `client` - Authenticated Spotify client
32/// * `query` - Search query
33/// * `types` - Optional list of types to search (defaults to all)
34/// * `limit` - Optional limit per type (default 20, max 50)
35/// * `market` - Optional market (ISO 3166-1 alpha-2 country code) for content availability
36///
37/// # Market Parameter
38///
39/// The market parameter is important for podcast/episode searches. Without it,
40/// the Spotify API may return incomplete episode data (missing show information).
41/// When provided, episodes will include their parent show's name and other metadata.
42///
43/// # Spotify API Quirk Workaround
44///
45/// The Spotify Search API has a known bug/quirk where requesting `limit=1`
46/// returns different (often incorrect) results compared to `limit=2`.
47///
48/// For example, searching "tool" with `limit=1` might return "Weezer" as the
49/// top artist, but `limit=2` correctly returns "TOOL" first, then "Weezer".
50///
51/// This appears to be a ranking/relevance calculation issue on Spotify's end
52/// where the algorithm behaves differently when only one result is requested.
53///
54/// **Workaround**: When `limit=1` is requested, we actually fetch `limit=2`
55/// from the API and then truncate the results to only return the first item.
56/// This ensures consistent and correct "top result" behavior.
57///
58/// This workaround was implemented on 2025-01-12 after observing the issue.
59/// If Spotify fixes this behavior in the future, this workaround can be removed.
60pub async fn search(
61    client: &SpotifyApi,
62    query: &str,
63    types: Option<&[&str]>,
64    limit: Option<u8>,
65    market: Option<&str>,
66) -> Result<Option<Value>, HttpError> {
67    let type_str = types
68        .map(|t| t.join(","))
69        .unwrap_or_else(|| SEARCH_TYPES.join(","));
70
71    let requested_limit = limit.unwrap_or(20).min(50);
72
73    // WORKAROUND: Spotify API returns incorrect results when limit=1.
74    // We fetch limit=2 instead and truncate the response.
75    // See function documentation for full explanation.
76    let api_limit = if requested_limit == 1 {
77        2
78    } else {
79        requested_limit
80    };
81    let needs_truncation = requested_limit == 1;
82
83    let endpoint = Endpoint::Search {
84        query,
85        types: &type_str,
86        limit: api_limit,
87        market,
88    }
89    .path();
90
91    let response = client.get(&endpoint).await?;
92
93    // If we requested limit=1, truncate all result arrays to single item
94    if needs_truncation && let Some(mut data) = response {
95        truncate_search_results(&mut data);
96        return Ok(Some(data));
97    }
98
99    Ok(response)
100}
101
102/// Truncates all search result arrays to contain only the first item.
103///
104/// This is part of the limit=1 workaround. The Spotify API response contains
105/// multiple result types (tracks, artists, albums, etc.), each with an "items"
106/// array. This function truncates each of those arrays to a single element.
107fn truncate_search_results(data: &mut Value) {
108    for result_type in SEARCH_RESULT_KEYS {
109        if let Some(container) = data.get_mut(result_type) {
110            if let Some(items) = container.get_mut("items")
111                && let Some(arr) = items.as_array_mut()
112            {
113                arr.truncate(1);
114            }
115            // Also update the limit field to reflect what was actually returned
116            if let Some(limit) = container.get_mut("limit") {
117                *limit = Value::Number(1.into());
118            }
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use serde_json::json;
127
128    #[test]
129    fn search_types_has_expected_types() {
130        assert!(SEARCH_TYPES.contains(&"track"));
131        assert!(SEARCH_TYPES.contains(&"artist"));
132        assert!(SEARCH_TYPES.contains(&"album"));
133        assert!(SEARCH_TYPES.contains(&"playlist"));
134        assert!(SEARCH_TYPES.contains(&"show"));
135        assert!(SEARCH_TYPES.contains(&"episode"));
136        assert!(SEARCH_TYPES.contains(&"audiobook"));
137    }
138
139    #[test]
140    fn search_types_count() {
141        assert_eq!(SEARCH_TYPES.len(), 7);
142    }
143
144    #[test]
145    fn search_result_keys_are_plural() {
146        for key in SEARCH_RESULT_KEYS {
147            assert!(key.ends_with('s'), "{} should be plural", key);
148        }
149    }
150
151    #[test]
152    fn search_result_keys_count() {
153        assert_eq!(SEARCH_RESULT_KEYS.len(), 7);
154    }
155
156    #[test]
157    fn truncate_search_results_works() {
158        let mut data = json!({
159            "tracks": {
160                "items": [
161                    {"name": "track1"},
162                    {"name": "track2"},
163                    {"name": "track3"}
164                ],
165                "limit": 3
166            },
167            "artists": {
168                "items": [
169                    {"name": "artist1"},
170                    {"name": "artist2"}
171                ],
172                "limit": 2
173            }
174        });
175
176        truncate_search_results(&mut data);
177
178        let tracks = data["tracks"]["items"].as_array().unwrap();
179        assert_eq!(tracks.len(), 1);
180        assert_eq!(tracks[0]["name"], "track1");
181
182        let artists = data["artists"]["items"].as_array().unwrap();
183        assert_eq!(artists.len(), 1);
184        assert_eq!(artists[0]["name"], "artist1");
185
186        assert_eq!(data["tracks"]["limit"], 1);
187        assert_eq!(data["artists"]["limit"], 1);
188    }
189
190    #[test]
191    fn truncate_handles_missing_keys() {
192        let mut data = json!({
193            "unknown_key": {
194                "items": [1, 2, 3]
195            }
196        });
197
198        truncate_search_results(&mut data);
199
200        // Should not crash and unknown key should be unchanged
201        assert_eq!(data["unknown_key"]["items"].as_array().unwrap().len(), 3);
202    }
203
204    #[test]
205    fn truncate_handles_empty_items() {
206        let mut data = json!({
207            "tracks": {
208                "items": [],
209                "limit": 0
210            }
211        });
212
213        truncate_search_results(&mut data);
214
215        assert_eq!(data["tracks"]["items"].as_array().unwrap().len(), 0);
216    }
217
218    #[test]
219    fn truncate_handles_single_item() {
220        let mut data = json!({
221            "albums": {
222                "items": [{"name": "album1"}],
223                "limit": 1
224            }
225        });
226
227        truncate_search_results(&mut data);
228
229        assert_eq!(data["albums"]["items"].as_array().unwrap().len(), 1);
230    }
231}