From dbc97d6bd12228e12a3c6ed8879e9cb1497c2b1d Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:34:32 -0600 Subject: [PATCH] feat(search): add request signing to all search endpoints Add request_ts + request_sig (MD5 signature) to catalog/search, album/search, track/search, artist/search, and playlist/search endpoints matching the mobile app authentication pattern. Migrate v2_search_all from legacy AppState to CoreBridge, removing the last direct API call that bypassed the V2 crate architecture. cc --- crates/qbz-core/src/core.rs | 17 ++++++++++ crates/qbz-qobuz/src/auth.rs | 19 +++++++++++ crates/qbz-qobuz/src/client.rs | 62 ++++++++++++++++++++++++++++++++-- src-tauri/src/commands_v2.rs | 37 ++++---------------- src-tauri/src/core_bridge.rs | 13 +++++++ 5 files changed, 115 insertions(+), 33 deletions(-) diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index 88c7e86f7..50cc9da70 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -408,6 +408,23 @@ impl QbzCore { .map_err(CoreError::Api) } + /// Catalog search (combined: albums, tracks, artists, playlists, most_popular). + /// Returns raw JSON for the caller to parse. + pub async fn catalog_search( + &self, + query: &str, + limit: u32, + offset: u32, + ) -> Result { + let client = self.client.read().await; + let client = client.as_ref().ok_or(CoreError::NotInitialized)?; + + client + .catalog_search(query, limit, offset) + .await + .map_err(CoreError::Api) + } + /// Get album by ID pub async fn get_album(&self, album_id: &str) -> Result { let client = self.client.read().await; diff --git a/crates/qbz-qobuz/src/auth.rs b/crates/qbz-qobuz/src/auth.rs index 19bc19da9..149fa2082 100644 --- a/crates/qbz-qobuz/src/auth.rs +++ b/crates/qbz-qobuz/src/auth.rs @@ -28,6 +28,25 @@ pub fn sign_get_favorites(timestamp: u64, secret: &str) -> String { generate_signature("favoritegetUserFavorites", "", timestamp, secret) } +/// Generate signature for search endpoints. +/// `method` is the concatenated endpoint name (e.g. "catalogsearch", "albumsearch"). +/// Query params are sorted alphabetically: limit, offset, query, [type]. +pub fn sign_search( + method: &str, + query: &str, + limit: u32, + offset: u32, + search_type: Option<&str>, + timestamp: u64, + secret: &str, +) -> String { + let mut params = format!("limit{}offset{}query{}", limit, offset, query); + if let Some(st) = search_type { + params.push_str(&format!("type{}", st)); + } + generate_signature(method, ¶ms, timestamp, secret) +} + /// Get current Unix timestamp pub fn get_timestamp() -> u64 { SystemTime::now() diff --git a/crates/qbz-qobuz/src/client.rs b/crates/qbz-qobuz/src/client.rs index 8fa06a543..5839c0398 100644 --- a/crates/qbz-qobuz/src/client.rs +++ b/crates/qbz-qobuz/src/client.rs @@ -7,7 +7,7 @@ use tokio::sync::RwLock; use super::auth::{ get_timestamp, parse_login_response, sign_file_url, sign_get_favorites, sign_get_file_url, - sign_session_start, + sign_search, sign_session_start, }; use super::bundle::{extract_bundle_tokens, BundleTokens}; use super::endpoints::{self, paths}; @@ -422,13 +422,19 @@ impl QobuzClient { search_type: Option<&str>, ) -> Result> { let url = endpoints::build_url(paths::ALBUM_SEARCH); + let timestamp = get_timestamp(); + let secret = self.secret().await?; + let signature = sign_search("albumsearch", query, limit, offset, search_type, timestamp, &secret); let limit_str = limit.to_string(); let offset_str = offset.to_string(); + let ts_str = timestamp.to_string(); let mut params: Vec<(&str, &str)> = vec![ ("query", query), ("limit", &limit_str), ("offset", &offset_str), + ("request_ts", &ts_str), + ("request_sig", &signature), ]; if let Some(st) = search_type { @@ -462,13 +468,19 @@ impl QobuzClient { search_type: Option<&str>, ) -> Result> { let url = endpoints::build_url(paths::TRACK_SEARCH); + let timestamp = get_timestamp(); + let secret = self.secret().await?; + let signature = sign_search("tracksearch", query, limit, offset, search_type, timestamp, &secret); let limit_str = limit.to_string(); let offset_str = offset.to_string(); + let ts_str = timestamp.to_string(); let mut params: Vec<(&str, &str)> = vec![ ("query", query), ("limit", &limit_str), ("offset", &offset_str), + ("request_ts", &ts_str), + ("request_sig", &signature), ]; if let Some(st) = search_type { @@ -502,13 +514,19 @@ impl QobuzClient { search_type: Option<&str>, ) -> Result> { let url = endpoints::build_url(paths::ARTIST_SEARCH); + let timestamp = get_timestamp(); + let secret = self.secret().await?; + let signature = sign_search("artistsearch", query, limit, offset, search_type, timestamp, &secret); let limit_str = limit.to_string(); let offset_str = offset.to_string(); + let ts_str = timestamp.to_string(); let mut params: Vec<(&str, &str)> = vec![ ("query", query), ("limit", &limit_str), ("offset", &offset_str), + ("request_ts", &ts_str), + ("request_sig", &signature), ]; if let Some(st) = search_type { @@ -532,6 +550,35 @@ impl QobuzClient { Ok(serde_json::from_value(artists.clone())?) } + /// Catalog search (combined: albums, tracks, artists, playlists, most_popular). + /// Returns raw JSON for caller to parse — the response shape is complex. + pub async fn catalog_search(&self, query: &str, limit: u32, offset: u32) -> Result { + let url = endpoints::build_url(paths::CATALOG_SEARCH); + let timestamp = get_timestamp(); + let secret = self.secret().await?; + let signature = sign_search("catalogsearch", query, limit, offset, None, timestamp, &secret); + let limit_str = limit.to_string(); + let offset_str = offset.to_string(); + let ts_str = timestamp.to_string(); + + let http_response = self + .http + .get(&url) + .headers(self.api_headers().await?) + .query(&[ + ("query", query), + ("limit", &limit_str), + ("offset", &offset_str), + ("request_ts", &ts_str), + ("request_sig", &signature), + ]) + .send() + .await?; + log::debug!("[API] catalog_search status={}", http_response.status()); + let response: Value = http_response.json().await?; + Ok(response) + } + /// Get similar artists for an artist ID pub async fn get_similar_artists( &self, @@ -1544,14 +1591,23 @@ impl QobuzClient { offset: u32, ) -> Result> { let url = endpoints::build_url(paths::PLAYLIST_SEARCH); + let timestamp = get_timestamp(); + let secret = self.secret().await?; + let signature = sign_search("playlistsearch", query, limit, offset, None, timestamp, &secret); + let limit_str = limit.to_string(); + let offset_str = offset.to_string(); + let ts_str = timestamp.to_string(); + let http_response = self .http .get(&url) .headers(self.api_headers().await?) .query(&[ ("query", query), - ("limit", &limit.to_string()), - ("offset", &offset.to_string()), + ("limit", &limit_str), + ("offset", &offset_str), + ("request_ts", &ts_str), + ("request_sig", &signature), ]) .send() .await?; diff --git a/src-tauri/src/commands_v2.rs b/src-tauri/src/commands_v2.rs index 1139915af..a8a61a2a2 100644 --- a/src-tauri/src/commands_v2.rs +++ b/src-tauri/src/commands_v2.rs @@ -7370,43 +7370,20 @@ pub async fn v2_search_artists( #[tauri::command] pub async fn v2_search_all( query: String, - state: State<'_, AppState>, + core_bridge: State<'_, CoreBridgeState>, blacklist_state: State<'_, BlacklistState>, runtime: State<'_, RuntimeManagerState>, ) -> Result { runtime .manager() - .check_requirements(CommandRequirement::RequiresUserSession) + .check_requirements(CommandRequirement::RequiresCoreBridgeAuth) .await?; - let url = crate::api::endpoints::build_url(crate::api::endpoints::paths::CATALOG_SEARCH); - let response: serde_json::Value = { - let client = state.client.read().await; - client - .get_http() - .get(&url) - .header( - "X-App-Id", - client - .app_id() - .await - .map_err(|e| RuntimeError::Internal(e.to_string()))?, - ) - .header( - "X-User-Auth-Token", - client - .auth_token() - .await - .map_err(|e| RuntimeError::Internal(e.to_string()))?, - ) - .query(&[("query", query.as_str()), ("limit", "30"), ("offset", "0")]) - .send() - .await - .map_err(|e| RuntimeError::Internal(format!("Request failed: {}", e)))? - .json() - .await - .map_err(|e| RuntimeError::Internal(format!("JSON parse failed: {}", e)))? - }; + let bridge = core_bridge.get().await; + let response: serde_json::Value = bridge + .catalog_search(&query, 30, 0) + .await + .map_err(RuntimeError::Internal)?; let mut albums: SearchResultsPage = response .get("albums") diff --git a/src-tauri/src/core_bridge.rs b/src-tauri/src/core_bridge.rs index 9e2bc385f..4e53c7daa 100644 --- a/src-tauri/src/core_bridge.rs +++ b/src-tauri/src/core_bridge.rs @@ -261,6 +261,19 @@ impl CoreBridge { .map_err(|e| e.to_string()) } + /// Catalog search (combined: albums, tracks, artists, playlists, most_popular). + pub async fn catalog_search( + &self, + query: &str, + limit: u32, + offset: u32, + ) -> Result { + self.core + .catalog_search(query, limit, offset) + .await + .map_err(|e| e.to_string()) + } + /// Get album by ID pub async fn get_album(&self, album_id: &str) -> Result { self.core