qobuz_api_rust/api/
service.rs

1use std::{env::var, iter::once};
2
3use reqwest::{
4    Client,
5    header::{HeaderName, HeaderValue},
6};
7
8use crate::{
9    errors::QobuzApiError::{
10        self, AuthenticationError, CredentialsError, HttpError, QobuzApiInitializationError,
11    },
12    models::Login,
13    utils::{
14        get_web_player_app_id, get_web_player_app_secret, read_app_credentials_from_env,
15        write_app_credentials_to_env,
16    },
17};
18
19/// Constants for the Qobuz API
20pub mod constants {
21    /// Base URL for the Qobuz API
22    ///
23    /// This is the main endpoint for all Qobuz API requests. The API version is included in the URL.
24    /// All API calls should be made relative to this base URL.
25    pub const API_BASE_URL: &str = "https://www.qobuz.com/api.json/0.2";
26    /// Base URL for the Qobuz Web Player
27    ///
28    /// This URL is used to extract application credentials from the Qobuz web player.
29    /// The library fetches app ID and app secret from the web player's JavaScript bundle.
30    pub const WEB_PLAYER_BASE_URL: &str = "https://play.qobuz.com";
31}
32
33/// The service disclosing the various endpoints of the Qobuz REST API.
34///
35/// The service can be initialized using your own 'app_id' and 'app_secret',
36/// or by letting the service attempt to fetch these 2 values from the Qobuz Web Player.
37///
38/// # Examples
39///
40/// Basic initialization with automatic credential fetching:
41///
42/// ```no_run
43/// use qobuz_api_rust::QobuzApiService;
44///
45/// #[tokio::main]
46/// async fn main() -> Result<(), qobuz_api_rust::QobuzApiError> {
47///     let service = QobuzApiService::new().await?;
48///     Ok(())
49/// }
50/// ```
51///
52/// Initialization with custom credentials:
53///
54/// ```no_run
55/// use qobuz_api_rust::QobuzApiService;
56///
57/// #[tokio::main]
58/// async fn main() -> Result<(), qobuz_api_rust::QobuzApiError> {
59///     let service = QobuzApiService::with_credentials(
60///         Some("your_app_id".to_string()),
61///         Some("your_app_secret".to_string())
62///     ).await?;
63///     Ok(())
64/// }
65/// ```
66pub struct QobuzApiService {
67    /// The application ID for the Qobuz API
68    ///
69    /// This is a unique identifier for your application registered with Qobuz.
70    /// It's used in API requests to identify the source of the request.
71    pub app_id: String,
72    /// The application secret for the Qobuz API
73    ///
74    /// This is a secret key associated with your application ID.
75    /// It's used to sign requests and authenticate with the API.
76    pub app_secret: String,
77    /// The user authentication token, if authenticated
78    ///
79    /// This token is obtained after successful user authentication and is used
80    /// for API requests that require user context.
81    pub user_auth_token: Option<String>,
82    /// HTTP client used for making API requests
83    ///
84    /// This client is configured with appropriate headers and user agent
85    /// for making requests to the Qobuz API.
86    pub(crate) client: Client,
87}
88
89impl QobuzApiService {
90    /// Initializes a new instance of the QobuzApiService using cached credentials from .env file
91    /// or by dynamically retrieving them from the Qobuz Web Player if not available.
92    ///
93    /// This method attempts to initialize the service in the following order:
94    /// 1. Try to use cached credentials from the .env file
95    /// 2. If cached credentials fail, fetch new ones from the web player
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if:
100    /// - Failed to fetch credentials from the web player
101    /// - Failed to create an HTTP client
102    /// - Both cached and fetched credentials are invalid
103    ///
104    /// # Examples
105    ///
106    /// ```no_run
107    /// use qobuz_api_rust::QobuzApiService;
108    ///
109    /// #[tokio::main]
110    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
111    ///     let service = QobuzApiService::new().await?;
112    ///     Ok(())
113    /// }
114    /// ```
115    pub async fn new() -> Result<Self, QobuzApiError> {
116        // First, try to read credentials from .env file
117        if let Ok((Some(cached_app_id), Some(cached_app_secret))) = read_app_credentials_from_env()
118        {
119            if !cached_app_id.is_empty() && !cached_app_secret.is_empty() {
120                println!("Using cached credentials from .env file");
121
122                // Try to initialize with cached credentials
123                match Self::with_credentials(
124                    Some(cached_app_id.clone()),
125                    Some(cached_app_secret.clone()),
126                )
127                .await
128                {
129                    Ok(service) => {
130                        return Ok(service);
131                    }
132                    Err(e) => {
133                        println!(
134                            "Cached credentials failed to initialize ({}), fetching new ones...",
135                            e
136                        );
137                    }
138                }
139            }
140        } else {
141            println!("No cached credentials found, fetching new ones...");
142        }
143
144        // Fetch fresh credentials from web player
145        let app_id = get_web_player_app_id()
146            .await
147            .map_err(|e| QobuzApiInitializationError {
148                message: format!("Failed to fetch app ID from web player: {}", e),
149            })?;
150
151        let app_secret =
152            get_web_player_app_secret()
153                .await
154                .map_err(|e| QobuzApiInitializationError {
155                    message: format!("Failed to fetch app secret from web player: {}", e),
156                })?;
157
158        // Store the fetched credentials in .env file for future use
159        if let Err(e) = write_app_credentials_to_env(&app_id, &app_secret) {
160            eprintln!("Warning: Failed to write credentials to .env file: {}", e);
161        } else {
162            println!("Successfully stored new credentials in .env file");
163        }
164
165        Self::with_credentials(Some(app_id), Some(app_secret)).await
166    }
167
168    /// Initializes a new instance of the QobuzApiService with custom app_id and app_secret.
169    ///
170    /// This method allows you to provide your own application credentials instead of
171    /// automatically fetching them from the web player.
172    ///
173    /// # Arguments
174    ///
175    /// * `app_id` - Optional application ID. If None, initialization will fail.
176    /// * `app_secret` - Optional application secret. If None, initialization will fail.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if:
181    /// - Either app_id or app_secret is None
182    /// - Failed to create an HTTP client with the provided credentials
183    ///
184    /// # Examples
185    ///
186    /// ```no_run
187    /// use qobuz_api_rust::QobuzApiService;
188    ///
189    /// #[tokio::main]
190    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
191    ///     let service = QobuzApiService::with_credentials(
192    ///         Some("your_app_id".to_string()),
193    ///         Some("your_app_secret".to_string())
194    ///     ).await?;
195    ///     Ok(())
196    /// }
197    /// ```
198    pub async fn with_credentials(
199        app_id: Option<String>,
200        app_secret: Option<String>,
201    ) -> Result<Self, QobuzApiError> {
202        let has_credentials = app_id.is_some() && app_secret.is_some();
203
204        if !has_credentials {
205            return Err(CredentialsError {
206                message: "App ID and App Secret must be provided".to_string(),
207            });
208        }
209
210        let app_id = match app_id {
211            Some(id) if !id.is_empty() => id,
212            _ => {
213                return Err(CredentialsError {
214                    message: "App ID cannot be empty".to_string(),
215                });
216            }
217        };
218
219        let app_secret = match app_secret {
220            Some(secret) if !secret.is_empty() => secret,
221            _ => {
222                return Err(CredentialsError {
223                    message: "App Secret cannot be empty".to_string(),
224                });
225            }
226        };
227
228        let client = Client::builder()
229            .user_agent(
230                "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0",
231            )
232            .default_headers(
233                once((
234                    HeaderName::from_static("x-app-id"),
235                    HeaderValue::from_str(&app_id).map_err(|e| QobuzApiInitializationError {
236                        message: format!("Failed to create header value for app ID: {}", e),
237                    })?,
238                ))
239                .collect(),
240            )
241            .build()
242            .map_err(HttpError)?;
243
244        let service = QobuzApiService {
245            app_id,
246            app_secret,
247            user_auth_token: None,
248            client,
249        };
250
251        Ok(service)
252    }
253
254    /// Sets the user authentication token for the service
255    ///
256    /// This method is used to set the user authentication token after successful
257    /// user authentication. The token will be used for subsequent API requests
258    /// that require user context.
259    ///
260    /// # Arguments
261    ///
262    /// * `token` - The user authentication token to set
263    ///
264    /// # Examples
265    ///
266    /// ```no_run
267    /// use qobuz_api_rust::QobuzApiService;
268    ///
269    /// #[tokio::main]
270    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
271    ///     let mut service = QobuzApiService::new().await?;
272    ///     service.set_user_auth_token("your_auth_token".to_string());
273    ///     Ok(())
274    /// }
275    /// ```
276    pub fn set_user_auth_token(&mut self, token: String) {
277        self.user_auth_token = Some(token);
278    }
279
280    /// Internal helper to perform authentication with given credentials
281    ///
282    /// This method will try different authentication methods based on the provided parameters:
283    /// 1. If both user_id and user_auth_token are provided, use token-based authentication
284    /// 2. If email and password are provided, use identifier/password authentication
285    /// 3. If username and password are provided, use identifier/password authentication
286    ///
287    /// # Arguments
288    ///
289    /// * `user_id` - Optional user ID for token-based authentication
290    /// * `user_auth_token` - Optional user authentication token
291    /// * `email` - Optional email for email/password authentication
292    /// * `password` - Optional password (MD5 hashed) for email/username authentication
293    /// * `username` - Optional username for username/password authentication
294    ///
295    /// # Returns
296    ///
297    /// * `Ok(Login)` - Login response containing user and auth token
298    /// * `Err(QobuzApiError)` - If authentication fails or no valid credentials are provided
299    ///
300    /// # Errors
301    ///
302    /// Returns an error if no valid combination of credentials is provided or if authentication fails.
303    pub(super) async fn authenticate_with_creds(
304        &mut self,
305        user_id: Option<&str>,
306        user_auth_token: Option<&str>,
307        email: Option<&str>,
308        password: Option<&str>,
309        username: Option<&str>,
310    ) -> Result<Login, QobuzApiError> {
311        // Check if both user_id and user_auth_token are provided
312        if let (Some(uid), Some(token)) = (user_id, user_auth_token)
313            && !uid.is_empty()
314            && !token.is_empty()
315        {
316            println!("Using token-based authentication");
317            return self.login_with_token(uid, token).await;
318        }
319
320        // Check if both email and password are provided
321        if let (Some(em), Some(pwd)) = (email, password)
322            && !em.is_empty()
323            && !pwd.is_empty()
324        {
325            println!("Using email/password authentication");
326            return self.login(em, pwd).await;
327        }
328
329        // Check if both username and password are provided
330        if let (Some(un), Some(pwd)) = (username, password)
331            && !un.is_empty()
332            && !pwd.is_empty()
333        {
334            println!("Using username/password authentication");
335            return self.login(un, pwd).await;
336        }
337
338        // If no valid combination of credentials is provided, return an error
339        Err(AuthenticationError {
340            message: "No valid authentication credentials provided. Please provide either: (user_id and user_auth_token) or (email and password) or (username and password)".to_string(),
341        })
342    }
343
344    /// Attempts to authenticate using environment variables.
345    ///
346    /// Checks for QOBUZ_USER_ID and QOBUZ_USER_AUTH_TOKEN first,
347    /// then falls back to QOBUZ_EMAIL and QOBUZ_PASSWORD,
348    /// then to QOBUZ_USERNAME and QOBUZ_PASSWORD.
349    /// Both email and username are treated as identifiers for authentication.
350    ///
351    /// # Returns
352    ///
353    /// * `Ok(Login)` - If authentication was successful
354    /// * `Err(QobuzApiError)` - If authentication failed or no valid credentials were found
355    ///
356    /// # Environment Variables
357    ///
358    /// This method looks for the following environment variables:
359    /// - `QOBUZ_USER_ID` and `QOBUZ_USER_AUTH_TOKEN` for token-based authentication
360    /// - `QOBUZ_EMAIL` and `QOBUZ_PASSWORD` for email/password authentication
361    /// - `QOBUZ_USERNAME` and `QOBUZ_PASSWORD` for username/password authentication
362    ///
363    /// # Errors
364    ///
365    /// Returns an error if no valid credentials are found in environment variables
366    /// or if authentication fails with the provided credentials.
367    ///
368    /// # Examples
369    ///
370    /// ```no_run
371    /// use qobuz_api_rust::QobuzApiService;
372    ///
373    /// #[tokio::main]
374    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
375    ///     let mut service = QobuzApiService::new().await?;
376    ///     let login_result = service.authenticate_with_env().await?;
377    ///     Ok(())
378    /// }
379    /// ```
380    pub async fn authenticate_with_env(&mut self) -> Result<Login, QobuzApiError> {
381        // Read environment variables
382        let user_id = var("QOBUZ_USER_ID").ok();
383        let user_auth_token = var("QOBUZ_USER_AUTH_TOKEN").ok();
384        let email = var("QOBUZ_EMAIL").ok();
385        let password = var("QOBUZ_PASSWORD").ok();
386        let username = var("QOBUZ_USERNAME").ok();
387
388        // Use the shared authentication helper function
389        self.authenticate_with_creds(
390            user_id.as_deref(),
391            user_auth_token.as_deref(),
392            email.as_deref(),
393            password.as_deref(),
394            username.as_deref(),
395        )
396        .await
397    }
398
399    /// Refreshes the app credentials by fetching new ones from the web player and updating the .env file
400    ///
401    /// This method creates a new instance of the service with fresh credentials
402    /// obtained from the Qobuz web player, while preserving the current user authentication token.
403    ///
404    /// # Errors
405    ///
406    /// Returns an error if:
407    /// - Failed to fetch new credentials from the web player
408    /// - Failed to create a new service instance with the fetched credentials
409    ///
410    /// # Examples
411    ///
412    /// ```no_run
413    /// use qobuz_api_rust::QobuzApiService;
414    ///
415    /// #[tokio::main]
416    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
417    ///     let service = QobuzApiService::new().await?;
418    ///     let updated_service = service.refresh_app_credentials().await?;
419    ///     Ok(())
420    /// }
421    /// ```
422    pub async fn refresh_app_credentials(&self) -> Result<Self, QobuzApiError> {
423        println!("Fetching new app credentials from web player...");
424
425        // Fetch fresh credentials from web player
426        let app_id = get_web_player_app_id()
427            .await
428            .map_err(|e| QobuzApiInitializationError {
429                message: format!("Failed to fetch app ID from web player: {}", e),
430            })?;
431
432        let app_secret =
433            get_web_player_app_secret()
434                .await
435                .map_err(|e| QobuzApiInitializationError {
436                    message: format!("Failed to fetch app secret from web player: {}", e),
437                })?;
438
439        // Store the new credentials in .env file
440        if let Err(e) = write_app_credentials_to_env(&app_id, &app_secret) {
441            eprintln!("Warning: Failed to update credentials in .env file: {}", e);
442        } else {
443            println!("Successfully updated credentials in .env file");
444        }
445
446        // Create a new service instance with the updated credentials and preserve the user auth token
447        let mut new_service = Self::with_credentials(Some(app_id), Some(app_secret)).await?;
448        new_service.user_auth_token = self.user_auth_token.clone();
449
450        Ok(new_service)
451    }
452}