spotify_cli/cli/commands/
mod.rs

1#[macro_use]
2mod macros;
3
4mod albums;
5mod audiobooks;
6mod auth;
7mod categories;
8mod chapters;
9#[cfg(unix)]
10mod daemon;
11mod episodes;
12mod follow;
13mod library;
14mod markets;
15pub(crate) mod now_playing;
16mod pin;
17mod player;
18mod playlist;
19mod resource;
20mod search;
21mod shows;
22mod user;
23
24pub use albums::*;
25pub use audiobooks::*;
26pub use auth::*;
27pub use categories::*;
28pub use chapters::*;
29#[cfg(unix)]
30pub use daemon::*;
31pub use episodes::*;
32pub use follow::*;
33pub use library::*;
34pub use markets::*;
35pub use pin::*;
36pub use player::*;
37pub use playlist::*;
38pub use resource::*;
39pub use search::*;
40pub use shows::*;
41pub use user::*;
42
43/// Search filters for Spotify API field queries
44#[derive(Default)]
45pub struct SearchFilters {
46    pub artist: Option<String>,
47    pub album: Option<String>,
48    pub track: Option<String>,
49    pub year: Option<String>,
50    pub genre: Option<String>,
51    pub isrc: Option<String>,
52    pub upc: Option<String>,
53    pub new: bool,
54    pub hipster: bool,
55}
56
57impl SearchFilters {
58    /// Build the full query string with filters appended
59    pub fn build_query(&self, base_query: &str) -> String {
60        let mut parts: Vec<String> = Vec::new();
61
62        if !base_query.is_empty() {
63            parts.push(base_query.to_string());
64        }
65
66        if let Some(ref artist) = self.artist {
67            parts.push(format!("artist:{}", artist));
68        }
69        if let Some(ref album) = self.album {
70            parts.push(format!("album:{}", album));
71        }
72        if let Some(ref track) = self.track {
73            parts.push(format!("track:{}", track));
74        }
75        if let Some(ref year) = self.year {
76            parts.push(format!("year:{}", year));
77        }
78        if let Some(ref genre) = self.genre {
79            parts.push(format!("genre:{}", genre));
80        }
81        if let Some(ref isrc) = self.isrc {
82            parts.push(format!("isrc:{}", isrc));
83        }
84        if let Some(ref upc) = self.upc {
85            parts.push(format!("upc:{}", upc));
86        }
87        if self.new {
88            parts.push("tag:new".to_string());
89        }
90        if self.hipster {
91            parts.push("tag:hipster".to_string());
92        }
93
94        parts.join(" ")
95    }
96
97    /// Check if any filters are set
98    pub fn has_filters(&self) -> bool {
99        self.artist.is_some()
100            || self.album.is_some()
101            || self.track.is_some()
102            || self.year.is_some()
103            || self.genre.is_some()
104            || self.isrc.is_some()
105            || self.upc.is_some()
106            || self.new
107            || self.hipster
108    }
109}
110
111use std::future::Future;
112
113use crate::http::api::SpotifyApi;
114use crate::io::output::{ErrorKind, Response};
115use crate::oauth::flow::OAuthFlow;
116use crate::storage::config::Config;
117use crate::storage::pins::{Pin, PinStore};
118use crate::storage::token_store::TokenStore;
119use tracing::info;
120
121/// Initialize a TokenStore with standardized error handling
122pub(crate) fn init_token_store() -> Result<TokenStore, Response> {
123    TokenStore::new().map_err(|e| {
124        Response::err_with_details(
125            500,
126            "Failed to initialize token store",
127            ErrorKind::Storage,
128            e.to_string(),
129        )
130    })
131}
132
133/// Initialize a PinStore with standardized error handling
134pub(crate) fn init_pin_store() -> Result<PinStore, Response> {
135    PinStore::new().map_err(|e| {
136        Response::err_with_details(
137            500,
138            "Failed to load pin store",
139            ErrorKind::Storage,
140            e.to_string(),
141        )
142    })
143}
144
145/// Load Config with standardized error handling
146pub(crate) fn load_config() -> Result<Config, Response> {
147    Config::load().map_err(|e| {
148        Response::err_with_details(
149            500,
150            "Failed to load config",
151            ErrorKind::Config,
152            e.to_string(),
153        )
154    })
155}
156
157/// Get an authenticated Spotify API client
158/// Automatically refreshes expired tokens when possible
159pub(crate) async fn get_authenticated_client() -> Result<SpotifyApi, Response> {
160    let token_store = init_token_store()?;
161
162    let token = token_store.load().map_err(|_| {
163        Response::err(
164            401,
165            "Not logged in. Run: spotify-cli auth login",
166            ErrorKind::Auth,
167        )
168    })?;
169
170    if token.is_expired() {
171        // Try to auto-refresh the token
172        let refresh_token = token.refresh_token.as_ref().ok_or_else(|| {
173            Response::err(
174                401,
175                "Token expired and no refresh token. Run: spotify-cli auth login",
176                ErrorKind::Auth,
177            )
178        })?;
179
180        let config = load_config()?;
181        let flow = OAuthFlow::new(config.client_id().to_string());
182
183        match flow.refresh(refresh_token).await {
184            Ok(new_token) => {
185                info!("Auto-refreshed expired token");
186                if let Err(e) = token_store.save(&new_token) {
187                    return Err(Response::err_with_details(
188                        500,
189                        "Failed to save refreshed token",
190                        ErrorKind::Storage,
191                        e.to_string(),
192                    ));
193                }
194                return Ok(SpotifyApi::new(new_token.access_token));
195            }
196            Err(_) => {
197                return Err(Response::err(
198                    401,
199                    "Token expired and refresh failed. Run: spotify-cli auth login",
200                    ErrorKind::Auth,
201                ));
202            }
203        }
204    }
205
206    Ok(SpotifyApi::new(token.access_token))
207}
208
209/// Extract Spotify ID from URL or pass through if already an ID
210pub(crate) fn extract_id(input: &str) -> String {
211    Pin::extract_id(input)
212}
213
214/// Execute a command with an authenticated Spotify client
215pub(crate) async fn with_client<F, Fut>(f: F) -> Response
216where
217    F: FnOnce(SpotifyApi) -> Fut,
218    Fut: Future<Output = Response>,
219{
220    match get_authenticated_client().await {
221        Ok(client) => f(client).await,
222        Err(e) => e,
223    }
224}