spotify_cli/cli/commands/search/
mod.rs

1//! Search command modules
2//!
3//! This module is organized into submodules:
4//! - `filters` - Result filtering (ghost entries, exact matches, URI extraction)
5//! - `pins` - Pin search with fuzzy matching
6//! - `playback` - Playback helpers for search results
7//! - `scoring` - Fuzzy scoring for Spotify results
8
9mod filters;
10mod pins;
11mod playback;
12mod scoring;
13
14use crate::endpoints::episodes::get_several_episodes;
15use crate::endpoints::search;
16use crate::endpoints::user::get_current_user;
17use crate::http::api::SpotifyApi;
18use crate::io::output::{ErrorKind, Response};
19use crate::storage::config::Config;
20use serde_json::Value;
21
22use super::{SearchFilters, with_client};
23use filters::{extract_first_uri, filter_exact_matches, filter_ghost_entries};
24use pins::search_pins;
25use playback::play_uri;
26use scoring::add_fuzzy_scores;
27
28/// Options for the search command
29pub struct SearchOptions {
30    pub limit: u8,
31    pub pins_only: bool,
32    pub exact: bool,
33    pub filters: SearchFilters,
34    pub play: bool,
35    pub sort: bool,
36}
37
38/// Enrich episodes with show information by fetching full episode details.
39/// Uses a show cache to avoid duplicating show data for episodes from the same show.
40async fn enrich_episodes(client: &SpotifyApi, results: &mut Value) {
41    let episodes = match results
42        .get("episodes")
43        .and_then(|e| e.get("items"))
44        .and_then(|i| i.as_array())
45    {
46        Some(eps) => eps,
47        None => return,
48    };
49
50    // Collect IDs of episodes missing show info
51    let ids: Vec<String> = episodes
52        .iter()
53        .filter(|ep| ep.get("show").is_none() || ep.get("show").unwrap().is_null())
54        .filter_map(|ep| ep.get("id").and_then(|id| id.as_str()).map(String::from))
55        .collect();
56
57    if ids.is_empty() {
58        return;
59    }
60
61    // Fetch full episode details in one batch call
62    let full_episodes = match get_several_episodes::get_several_episodes(client, &ids).await {
63        Ok(Some(data)) => data,
64        _ => return,
65    };
66
67    // Build a cache of show_id -> show info (each unique show stored once)
68    let mut show_cache: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
69    // Map episode_id -> show_id for lookup
70    let mut episode_show_map: std::collections::HashMap<String, String> =
71        std::collections::HashMap::new();
72
73    if let Some(eps) = full_episodes.get("episodes").and_then(|e| e.as_array()) {
74        for ep in eps {
75            if let (Some(ep_id), Some(show)) =
76                (ep.get("id").and_then(|id| id.as_str()), ep.get("show"))
77                && let Some(show_id) = show.get("id").and_then(|id| id.as_str())
78            {
79                // Cache show by its ID (only store once per unique show)
80                show_cache
81                    .entry(show_id.to_string())
82                    .or_insert_with(|| show.clone());
83                // Map episode to its show
84                episode_show_map.insert(ep_id.to_string(), show_id.to_string());
85            }
86        }
87    }
88
89    // Merge show info back into search results using the cache
90    if let Some(items) = results
91        .get_mut("episodes")
92        .and_then(|e| e.get_mut("items"))
93        .and_then(|i| i.as_array_mut())
94    {
95        for ep in items.iter_mut() {
96            if let Some(ep_id) = ep.get("id").and_then(|id| id.as_str())
97                && let Some(show_id) = episode_show_map.get(ep_id)
98                && let Some(show) = show_cache.get(show_id)
99            {
100                ep.as_object_mut()
101                    .map(|obj| obj.insert("show".to_string(), show.clone()));
102            }
103        }
104    }
105}
106
107pub async fn search_command(query: &str, types: &[String], options: SearchOptions) -> Response {
108    // Build the full query with filters
109    let full_query = options.filters.build_query(query);
110
111    // Validate that we have something to search for
112    if full_query.is_empty() {
113        return Response::err(
114            400,
115            "Search query is empty. Provide a query or use filters (--artist, --album, etc.)",
116            ErrorKind::Validation,
117        );
118    }
119
120    // First, search pins with fuzzy matching (uses base query only)
121    let pin_results = search_pins(query);
122
123    if options.pins_only {
124        return Response::success_with_payload(
125            200,
126            format!("Found {} pinned result(s)", pin_results.len()),
127            serde_json::json!({
128                "pins": pin_results,
129                "spotify": null
130            }),
131        );
132    }
133
134    // Prepare data for closure
135    let query = query.to_string();
136    let types = types.to_vec();
137
138    with_client(|client| async move {
139        let type_strs: Vec<&str> = if types.is_empty() {
140            search::SEARCH_TYPES.to_vec()
141        } else {
142            types.iter().map(|s| s.as_str()).collect()
143        };
144
145        // Load config for fuzzy settings
146        let config = Config::load().ok();
147        let fuzzy_config = config
148            .as_ref()
149            .map(|c| c.fuzzy().clone())
150            .unwrap_or_default();
151        // Use --sort flag or fall back to config setting
152        let sort_by_score =
153            options.sort || config.as_ref().map(|c| c.sort_by_score()).unwrap_or(false);
154
155        // Fetch user's market for proper podcast/episode results
156        let market = match get_current_user::get_current_user(&client).await {
157            Ok(Some(user)) => user
158                .get("country")
159                .and_then(|c| c.as_str())
160                .map(String::from),
161            _ => None,
162        };
163
164        match search::search(
165            &client,
166            &full_query,
167            Some(&type_strs),
168            Some(options.limit),
169            market.as_deref(),
170        )
171        .await
172        {
173            Ok(Some(mut spotify_results)) => {
174                filter_ghost_entries(&mut spotify_results);
175
176                // Enrich episodes with show info (search API returns simplified objects)
177                enrich_episodes(&client, &mut spotify_results).await;
178
179                if options.exact {
180                    filter_exact_matches(&mut spotify_results, &query);
181                }
182
183                add_fuzzy_scores(&mut spotify_results, &query, &fuzzy_config, sort_by_score);
184
185                if options.play {
186                    if let Some(uri) = extract_first_uri(&pin_results, &spotify_results) {
187                        return play_uri(&client, &uri).await;
188                    } else {
189                        return Response::err(404, "No results to play", ErrorKind::NotFound);
190                    }
191                }
192
193                Response::success_with_payload(
194                    200,
195                    format!("Found {} pinned + Spotify results", pin_results.len()),
196                    serde_json::json!({
197                        "pins": pin_results,
198                        "spotify": spotify_results
199                    }),
200                )
201            }
202            Ok(None) => {
203                if options.play
204                    && !pin_results.is_empty()
205                    && let Some(uri) = extract_first_uri(&pin_results, &serde_json::json!({}))
206                {
207                    return play_uri(&client, &uri).await;
208                }
209                Response::success_with_payload(
210                    200,
211                    format!("Found {} pinned result(s)", pin_results.len()),
212                    serde_json::json!({
213                        "pins": pin_results,
214                        "spotify": {}
215                    }),
216                )
217            }
218            Err(e) => Response::from_http_error(&e, "Search failed"),
219        }
220    })
221    .await
222}