use anyhow::{bail, Result}; use reqwest::Client; use serde_json::Value; use std::time::{SystemTime, UNIX_EPOCH}; use super::models::*; const BASE_URL: &str = "https://www.qobuz.com/api.json/0.2/"; const USER_AGENT: &str = "Dalvik/2.1.0 (Linux; U; Android 9; Nexus 6P Build/PQ3A.190801.002) QobuzMobileAndroid/9.7.0.3-b26022717"; const APP_VERSION: &str = "9.7.0.3"; pub const DEFAULT_APP_ID: &str = "312369995"; pub const DEFAULT_APP_SECRET: &str = "e79f8b9be485692b0e5f9dd895826368"; pub struct QobuzClient { http: Client, pub auth_token: Option, app_id: String, app_secret: String, } impl QobuzClient { pub fn new() -> Result { Self::new_with_config(None, None) } pub fn new_with_config(app_id: Option<&str>, app_secret: Option<&str>) -> Result { let app_id = app_id.unwrap_or(DEFAULT_APP_ID).to_string(); let app_secret = app_secret.unwrap_or(DEFAULT_APP_SECRET).to_string(); let http = Client::builder() .user_agent(USER_AGENT) .default_headers({ let mut h = reqwest::header::HeaderMap::new(); h.insert("X-App-Id", app_id.parse()?); h.insert("X-App-Version", APP_VERSION.parse()?); h.insert("X-Device-Platform", "android".parse()?); h.insert("X-Device-Model", "Nexus 6P".parse()?); h.insert("X-Device-Os-Version", "9".parse()?); h }) .build()?; Ok(Self { http, auth_token: None, app_id, app_secret, }) } pub fn set_auth_token(&mut self, token: String) { self.auth_token = Some(token); } fn ts() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() } /// Compute the request signature required by the Qobuz API. /// NOTE: MD5 is mandated by the Qobuz API protocol — not our choice. fn request_sig(&self, method: &str, params: &mut Vec<(&str, String)>, ts: u64) -> String { params.sort_by_key(|(k, _)| *k); let mut s = method.replace('/', ""); for (k, v) in params.iter() { s.push_str(k); s.push_str(v); } s.push_str(&ts.to_string()); s.push_str(&self.app_secret); format!("{:x}", md5::compute(s.as_bytes())) } fn url(&self, method: &str) -> String { format!("{}{}", BASE_URL, method) } async fn check_response(resp: reqwest::Response) -> Result { let status = resp.status(); let body: Value = resp.json().await?; if !status.is_success() { let msg = body .get("message") .and_then(|m| m.as_str()) .unwrap_or("unknown API error"); bail!("HTTP {}: {}", status, msg); } Ok(body) } fn post_request(&self, method: &str) -> reqwest::RequestBuilder { let mut builder = self.http.post(self.url(method)); builder = builder.query(&[("app_id", self.app_id.as_str())]); if let Some(token) = &self.auth_token { builder = builder.header("Authorization", format!("Bearer {}", token)); } builder } fn get_request(&self, method: &str) -> reqwest::RequestBuilder { let mut builder = self.http.get(self.url(method)); builder = builder.query(&[("app_id", self.app_id.as_str())]); if let Some(token) = &self.auth_token { builder = builder.header("Authorization", format!("Bearer {}", token)); } builder } // --- Auth --- pub async fn login(&mut self, email: &str, password: &str) -> Result { match self.oauth2_login(email, password).await { Ok(r) => Ok(r), Err(_) => self.legacy_login(email, password).await, } } /// NOTE: Qobuz API requires credentials as GET query params — not our choice. async fn oauth2_login(&mut self, email: &str, password: &str) -> Result { let ts = Self::ts(); let mut sign_params: Vec<(&str, String)> = vec![ ("password", password.to_string()), ("username", email.to_string()), ]; let sig = self.request_sig("oauth2login", &mut sign_params, ts); let resp = self .http .get(self.url("oauth2/login")) .query(&[ ("app_id", self.app_id.as_str()), ("username", email), ("password", password), ("request_ts", ts.to_string().as_str()), ("request_sig", sig.as_str()), ]) .send() .await?; let status = resp.status(); let body: Value = resp.json().await?; if !status.is_success() { let msg = body.get("message").and_then(|m| m.as_str()).unwrap_or("login failed"); bail!("oauth2 login failed ({}): {}", status, msg); } self.extract_and_store_token(serde_json::from_value(body)?) } async fn legacy_login(&mut self, email: &str, password: &str) -> Result { let ts = Self::ts(); let mut sign_params: Vec<(&str, String)> = vec![ ("email", email.to_string()), ("password", password.to_string()), ]; let sig = self.request_sig("userlogin", &mut sign_params, ts); let resp = self .http .get(self.url("user/login")) .query(&[ ("app_id", self.app_id.as_str()), ("email", email), ("password", password), ("request_ts", ts.to_string().as_str()), ("request_sig", sig.as_str()), ]) .send() .await?; let status = resp.status(); let body: Value = resp.json().await?; if !status.is_success() { let msg = body.get("message").and_then(|m| m.as_str()).unwrap_or("login failed"); bail!("user login failed ({}): {}", status, msg); } self.extract_and_store_token(serde_json::from_value(body)?) } fn extract_and_store_token(&mut self, login: OAuthLoginResponse) -> Result { if let Some(token) = login .oauth2 .as_ref() .and_then(|o| o.access_token.clone()) .or_else(|| login.user_auth_token.clone()) { self.auth_token = Some(token); } Ok(login) } // --- User --- pub async fn get_user(&self) -> Result { let resp = self.get_request("user/get").send().await?; let body = Self::check_response(resp).await?; let user: UserDto = serde_json::from_value(body["user"].clone()) .or_else(|_| serde_json::from_value(body.clone()))?; Ok(user) } // --- Track --- pub async fn get_track(&self, track_id: i64) -> Result { let resp = self .get_request("track/get") .query(&[("track_id", track_id.to_string())]) .send() .await?; let body = Self::check_response(resp).await?; Ok(serde_json::from_value(body)?) } pub async fn get_track_url(&self, track_id: i64, format: Format) -> Result { let ts = Self::ts(); let intent = "stream"; let mut sign_params: Vec<(&str, String)> = vec![ ("format_id", format.id().to_string()), ("intent", intent.to_string()), ("track_id", track_id.to_string()), ]; let sig = self.request_sig("trackgetFileUrl", &mut sign_params, ts); let resp = self .get_request("track/getFileUrl") .query(&[ ("track_id", track_id.to_string()), ("format_id", format.id().to_string()), ("intent", intent.to_string()), ("request_ts", ts.to_string()), ("request_sig", sig), ]) .send() .await?; let body = Self::check_response(resp).await?; Ok(serde_json::from_value(body)?) } // --- Album --- pub async fn get_album(&self, album_id: &str) -> Result { let resp = self .get_request("album/get") .query(&[("album_id", album_id), ("limit", "50"), ("offset", "0")]) .send() .await?; let body = Self::check_response(resp).await?; Ok(serde_json::from_value(body)?) } // --- Artist --- pub async fn get_artist(&self, artist_id: i64) -> Result { let resp = self .get_request("artist/get") .query(&[ ("artist_id", artist_id.to_string()), ("extra", "albums".to_string()), ("albums_limit", "200".to_string()), ]) .send() .await?; let body = Self::check_response(resp).await?; Ok(serde_json::from_value(body)?) } pub async fn get_artist_page(&self, artist_id: i64) -> Result { let resp = self .get_request("artist/page") .query(&[("artist_id", artist_id.to_string())]) .send() .await?; Self::check_response(resp).await } // --- Search --- pub async fn search(&self, query: &str, offset: u32, limit: u32) -> Result { let resp = self .get_request("catalog/search") .query(&[ ("query", query), ("offset", &offset.to_string()), ("limit", &limit.to_string()), ]) .send() .await?; let body = Self::check_response(resp).await?; Ok(serde_json::from_value(body)?) } // --- Favorites / Library --- pub async fn get_user_playlists(&self, offset: u32, limit: u32) -> Result { let resp = self .get_request("playlist/getUserPlaylists") .query(&[("offset", &offset.to_string()), ("limit", &limit.to_string())]) .send() .await?; let body = Self::check_response(resp).await?; Ok(serde_json::from_value(body)?) } pub async fn get_playlist(&self, playlist_id: i64, offset: u32, limit: u32) -> Result { let resp = self .get_request("playlist/get") .query(&[ ("playlist_id", &playlist_id.to_string()), ("extra", &"tracks".to_string()), ("offset", &offset.to_string()), ("limit", &limit.to_string()), ]) .send() .await?; let body = Self::check_response(resp).await?; Ok(serde_json::from_value(body)?) } pub async fn get_fav_tracks(&self, offset: u32, limit: u32) -> Result> { let resp = self .get_request("favorite/getUserFavorites") .query(&[("type", "tracks"), ("offset", &offset.to_string()), ("limit", &limit.to_string())]) .send() .await?; let body = Self::check_response(resp).await?; Ok(serde_json::from_value(body["tracks"].clone())?) } pub async fn get_fav_albums(&self, offset: u32, limit: u32) -> Result> { let resp = self .get_request("favorite/getUserFavorites") .query(&[("type", "albums"), ("offset", &offset.to_string()), ("limit", &limit.to_string())]) .send() .await?; let body = Self::check_response(resp).await?; Ok(serde_json::from_value(body["albums"].clone())?) } pub async fn get_fav_artists(&self, offset: u32, limit: u32) -> Result> { let resp = self .get_request("favorite/getUserFavorites") .query(&[("type", "artists"), ("offset", &offset.to_string()), ("limit", &limit.to_string())]) .send() .await?; let body = Self::check_response(resp).await?; Ok(serde_json::from_value(body["artists"].clone())?) } // --- Playlist management --- pub async fn create_playlist(&self, name: &str) -> Result { let resp = self .post_request("playlist/create") .form(&[("name", name), ("is_public", "false"), ("is_collaborative", "false")]) .send() .await?; let body = Self::check_response(resp).await?; Ok(serde_json::from_value(body)?) } pub async fn add_track_to_playlist(&self, playlist_id: i64, track_id: i64) -> Result<()> { let resp = self .post_request("playlist/addTracks") .form(&[ ("playlist_id", playlist_id.to_string()), ("track_ids", track_id.to_string()), ("no_duplicate", "true".to_string()), ]) .send() .await?; Self::check_response(resp).await?; Ok(()) } pub async fn delete_playlist(&self, playlist_id: i64) -> Result<()> { let resp = self .get_request("playlist/delete") .query(&[("playlist_id", &playlist_id.to_string())]) .send() .await?; Self::check_response(resp).await?; Ok(()) } pub async fn delete_track_from_playlist(&self, playlist_id: i64, playlist_track_id: i64) -> Result<()> { let resp = self .post_request("playlist/deleteTracks") .form(&[ ("playlist_id", playlist_id.to_string()), ("playlist_track_ids", playlist_track_id.to_string()), ]) .send() .await?; Self::check_response(resp).await?; Ok(()) } pub async fn add_fav_track(&self, track_id: i64) -> Result<()> { let resp = self .get_request("favorite/create") .query(&[("type", "tracks"), ("track_ids", &track_id.to_string())]) .send() .await?; Self::check_response(resp).await?; Ok(()) } pub async fn remove_fav_track(&self, track_id: i64) -> Result<()> { let resp = self .get_request("favorite/delete") .query(&[("type", "tracks"), ("track_ids", &track_id.to_string())]) .send() .await?; Self::check_response(resp).await?; Ok(()) } pub async fn add_fav_album(&self, album_id: &str) -> Result<()> { let resp = self .get_request("favorite/create") .query(&[("type", "albums"), ("album_ids", album_id)]) .send() .await?; Self::check_response(resp).await?; Ok(()) } pub async fn remove_fav_album(&self, album_id: &str) -> Result<()> { let resp = self .get_request("favorite/delete") .query(&[("type", "albums"), ("album_ids", album_id)]) .send() .await?; Self::check_response(resp).await?; Ok(()) } }