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}