use std::{
env::{VarError, var},
path::Path,
};
use {
md5::{Digest, Md5},
serde::Deserialize,
tokio::runtime::Runtime,
tracing::{error, info},
};
use crate::{
api::{http_client::HttpClient, requests, service::QobuzApiService},
credentials::{save_app_credentials, web::extract_from_web_player},
errors::QobuzApiError::{self, AuthenticationError, CredentialsError},
signing::to_hex,
};
#[derive(Deserialize)]
struct LoginResponse {
user_auth_token: Option<String>,
user: Option<LoginUser>,
}
#[derive(Deserialize)]
struct LoginUser {
id: i64,
}
pub fn authenticate_with_env(service: &mut QobuzApiService) -> Result<(), QobuzApiError> {
authenticate_with_env_from(service, |key| var(key))
}
pub fn authenticate_with_env_from<E>(
service: &mut QobuzApiService,
get_env: E,
) -> Result<(), QobuzApiError>
where
E: Fn(&str) -> Result<String, VarError>,
{
if let (Ok(user_id), Ok(token)) = (get_env("QOBUZ_USER_ID"), get_env("QOBUZ_USER_AUTH_TOKEN")) {
info!("Using token-based authentication from environment");
let rt = Runtime::new()?;
rt.block_on(login_with_token_inner(
service.http_client(),
service.base_url(),
&service.app_id,
user_id.trim(),
token.trim(),
))?;
service.set_auth_token(token.trim().to_string());
info!(method = "token", "Authentication successful");
return Ok(());
}
let email = get_env("QOBUZ_EMAIL")
.or_else(|_| get_env("QOBUZ_USERNAME"))
.map_err(|var_error| AuthenticationError {
message: format!(
"No QOBUZ_USER_ID/QOBUZ_USER_AUTH_TOKEN or QOBUZ_EMAIL/QOBUZ_PASSWORD environment \
variables found: {var_error}"
),
})?;
let password = get_env("QOBUZ_PASSWORD").map_err(|var_error| AuthenticationError {
message: format!("QOBUZ_PASSWORD environment variable not found: {var_error}"),
})?;
info!("Using email/password authentication from environment");
let rt = Runtime::new()?;
let token = rt.block_on(login_inner(
service.http_client(),
service.base_url(),
&service.app_id,
email.trim(),
password.trim(),
))?;
service.set_auth_token(token);
info!(method = "email", "Authentication successful");
Ok(())
}
pub fn login(
service: &mut QobuzApiService,
email: &str,
password: &str,
) -> Result<(), QobuzApiError> {
info!(email = email, "Attempting login");
let rt = Runtime::new()?;
let token = rt.block_on(login_inner(
service.http_client(),
service.base_url(),
&service.app_id,
email,
password,
))?;
service.set_auth_token(token);
info!("Login successful");
Ok(())
}
async fn login_inner(
client: &dyn HttpClient,
base_url: &str,
app_id: &str,
email: &str,
password: &str,
) -> Result<String, QobuzApiError> {
let hashed = to_hex(&Md5::digest(password.as_bytes()));
let mut params = vec![
("email".to_string(), email.to_string()),
("password".to_string(), hashed),
];
let response: LoginResponse =
requests::post(client, base_url, "/user/login", &mut params, app_id, "").await?;
response.user_auth_token.ok_or_else(|| {
let err = AuthenticationError {
message: "Login succeeded but no user_auth_token in response".to_string(),
};
error!(error = %err, "Login response missing user_auth_token");
err
})
}
pub fn login_with_token(
service: &mut QobuzApiService,
user_id: &str,
auth_token: &str,
) -> Result<(), QobuzApiError> {
info!(user_id = user_id, "Attempting token login");
let rt = Runtime::new()?;
rt.block_on(login_with_token_inner(
service.http_client(),
service.base_url(),
&service.app_id,
user_id,
auth_token,
))?;
service.set_auth_token(auth_token.to_string());
info!("Token login successful");
Ok(())
}
async fn login_with_token_inner(
client: &dyn HttpClient,
base_url: &str,
app_id: &str,
user_id: &str,
auth_token: &str,
) -> Result<(), QobuzApiError> {
let mut params = vec![
("user_id".to_string(), user_id.to_string()),
("user_auth_token".to_string(), auth_token.to_string()),
];
let response: LoginResponse =
requests::post(client, base_url, "/user/login", &mut params, app_id, "").await?;
if response.user_auth_token.is_none() {
let err = AuthenticationError {
message: "Token login succeeded but no user_auth_token in response".to_string(),
};
error!(error = %err, "Token login response missing user_auth_token");
return Err(err);
}
if let Some(user) = &response.user {
let returned_id = user.id.to_string();
if returned_id != user_id {
let err = AuthenticationError {
message: format!(
"User ID mismatch: requested {user_id} but API returned {returned_id}"
),
};
error!(error = %err, "Token login user ID mismatch");
return Err(err);
}
}
Ok(())
}
pub fn refresh_app_credentials(service: &mut QobuzApiService) -> Result<(), QobuzApiError> {
if service.is_credentials_refreshed() {
let err = CredentialsError {
message: "Credentials can only be refreshed once per session".to_string(),
};
error!(error = %err, "Credential refresh attempted more than once per session");
return Err(err);
}
info!("Refreshing app credentials from web player");
let rt = Runtime::new()?;
let (app_id, app_secret) = rt.block_on(extract_from_web_player()).map_err(|e| {
error!(error = %e, "Web player credential extraction failed; manual configuration required");
e
})?;
let env_path = Path::new(".env");
save_app_credentials(env_path, &app_id, &app_secret)?;
service.app_id = app_id;
service.app_secret = app_secret;
service.rebuild_http_client()?;
service.mark_credentials_refreshed();
info!("App credentials refreshed successfully");
Ok(())
}
#[cfg(test)]
mod auth_tests;