From d8bd93374fc997dc6ee9cd424f5af72c3e447ebe Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 1 Feb 2026 01:33:17 -0600 Subject: [PATCH] feat(search): use Qobuz API most_popular field directly Replace heuristic-based most popular detection with Qobuz's actual most_popular field from the catalog/search endpoint. - Add CATALOG_SEARCH endpoint path - Make app_id() and get_http() public on QobuzClient - Add MostPopularItem enum with tagged serialization (artists/albums/tracks) - Update search_all to use catalog/search and parse most_popular field - Update SearchView template to use allResults.most_popular with new types - Add MostPopularItem type to searchState store --- src-tauri/src/api/client.rs | 9 ++- src-tauri/src/api/endpoints.rs | 3 + src-tauri/src/commands/search.rs | 70 ++++++++++++++++++---- src/lib/components/views/SearchView.svelte | 63 +++---------------- src/lib/stores/searchState.ts | 7 +++ 5 files changed, 84 insertions(+), 68 deletions(-) diff --git a/src-tauri/src/api/client.rs b/src-tauri/src/api/client.rs index 9a5ec8272..ef5954899 100644 --- a/src-tauri/src/api/client.rs +++ b/src-tauri/src/api/client.rs @@ -73,8 +73,8 @@ impl QobuzClient { self.locale.read().await.clone() } - /// Get app ID - async fn app_id(&self) -> Result { + /// Get app ID (public for catalog search) + pub async fn app_id(&self) -> Result { self.tokens .read() .await @@ -83,6 +83,11 @@ impl QobuzClient { .ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string())) } + /// Get HTTP client reference (public for catalog search) + pub fn get_http(&self) -> &Client { + &self.http + } + /// Get validated secret (validates on first use) async fn secret(&self) -> Result { // Check if we already have a validated secret diff --git a/src-tauri/src/api/endpoints.rs b/src-tauri/src/api/endpoints.rs index 505e93fef..8a0a40b2d 100644 --- a/src-tauri/src/api/endpoints.rs +++ b/src-tauri/src/api/endpoints.rs @@ -39,6 +39,9 @@ pub mod paths { // Label pub const LABEL_GET: &str = "/label/get"; + + // Catalog (combined search) + pub const CATALOG_SEARCH: &str = "/catalog/search"; } /// Build full URL for an endpoint diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index 60487a1b1..8e3325f3a 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -2,16 +2,27 @@ use tauri::State; -use crate::api::{Album, Artist, ArtistAlbums, LabelDetail, SearchResultsPage, Track}; +use crate::api::{endpoints, endpoints::paths, Album, Artist, ArtistAlbums, LabelDetail, SearchResultsPage, Track}; use crate::api_cache::ApiCacheState; use crate::AppState; use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Most popular item from catalog search - can be a track, album, or artist +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "content", rename_all = "lowercase")] +pub enum MostPopularItem { + Tracks(Track), + Albums(Album), + Artists(Artist), +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SearchAllResults { pub albums: SearchResultsPage, pub tracks: SearchResultsPage, pub artists: SearchResultsPage, + pub most_popular: Option, } #[tauri::command] @@ -62,22 +73,59 @@ pub async fn search_artists( #[tauri::command] pub async fn search_all( query: String, - search_type: Option, state: State<'_, AppState>, ) -> Result { let client = state.client.lock().await; - let st = search_type.as_deref(); - let (albums_result, tracks_result, artists_result) = tokio::join!( - client.search_albums(&query, 30, 0, st), - client.search_tracks(&query, 8, 0, st), - client.search_artists(&query, 12, 0, st) - ); + // Use catalog/search endpoint which returns everything including most_popular + let url = endpoints::build_url(paths::CATALOG_SEARCH); + let response: Value = client + .get_http() + .get(&url) + .header("X-App-Id", client.app_id().await.map_err(|e| e.to_string())?) + .query(&[ + ("query", query.as_str()), + ("limit", "30"), + ("offset", "0"), + ]) + .send() + .await + .map_err(|e| format!("Request failed: {}", e))? + .json() + .await + .map_err(|e| format!("JSON parse failed: {}", e))?; + + // Parse albums + let albums: SearchResultsPage = response + .get("albums") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_else(|| SearchResultsPage { items: vec![], total: 0, offset: 0, limit: 30 }); + + // Parse tracks + let tracks: SearchResultsPage = response + .get("tracks") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_else(|| SearchResultsPage { items: vec![], total: 0, offset: 0, limit: 30 }); + + // Parse artists + let artists: SearchResultsPage = response + .get("artists") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_else(|| SearchResultsPage { items: vec![], total: 0, offset: 0, limit: 30 }); + + // Parse most_popular - get the first item + let most_popular: Option = response + .get("most_popular") + .and_then(|mp| mp.get("items")) + .and_then(|items| items.as_array()) + .and_then(|arr| arr.first()) + .and_then(|item| serde_json::from_value(item.clone()).ok()); Ok(SearchAllResults { - albums: albums_result.map_err(|e| e.to_string())?, - tracks: tracks_result.map_err(|e| e.to_string())?, - artists: artists_result.map_err(|e| e.to_string())?, + albums, + tracks, + artists, + most_popular, }) } diff --git a/src/lib/components/views/SearchView.svelte b/src/lib/components/views/SearchView.svelte index f2b892fd8..8503f0d5d 100644 --- a/src/lib/components/views/SearchView.svelte +++ b/src/lib/components/views/SearchView.svelte @@ -220,55 +220,8 @@ let hasMoreTracks = $derived(trackResults ? trackResults.offset + trackResults.items.length < trackResults.total : false); let hasMoreArtists = $derived(artistResults ? artistResults.offset + artistResults.items.length < artistResults.total : false); - // Determine the "Most Popular" result based on query matching - // Priority: exact match > starts with > contains > default to first artist - type MostPopularType = 'artist' | 'album' | 'track'; - interface MostPopularResult { - type: MostPopularType; - artist?: Artist; - album?: Album; - track?: Track; - } - - let mostPopularResult = $derived.by((): MostPopularResult | null => { - if (!allResults) return null; - - const q = query.toLowerCase().trim(); - if (!q) return null; - - const firstArtist = allResults.artists.items[0]; - const firstAlbum = allResults.albums.items[0]; - const firstTrack = allResults.tracks.items[0]; - - // Check for exact matches first - if (firstArtist && firstArtist.name.toLowerCase() === q) { - return { type: 'artist', artist: firstArtist }; - } - if (firstAlbum && firstAlbum.title.toLowerCase() === q) { - return { type: 'album', album: firstAlbum }; - } - if (firstTrack && firstTrack.title.toLowerCase() === q) { - return { type: 'track', track: firstTrack }; - } - - // Check for "starts with" matches - if (firstArtist && firstArtist.name.toLowerCase().startsWith(q)) { - return { type: 'artist', artist: firstArtist }; - } - if (firstAlbum && firstAlbum.title.toLowerCase().startsWith(q)) { - return { type: 'album', album: firstAlbum }; - } - if (firstTrack && firstTrack.title.toLowerCase().startsWith(q)) { - return { type: 'track', track: firstTrack }; - } - - // Default: return artist if available, then album, then track - if (firstArtist) return { type: 'artist', artist: firstArtist }; - if (firstAlbum) return { type: 'album', album: firstAlbum }; - if (firstTrack) return { type: 'track', track: firstTrack }; - - return null; - }); + // Most popular result comes directly from Qobuz's catalog/search API + // No heuristics needed - Qobuz determines the most relevant result function debounceSearch() { if (searchTimeout) clearTimeout(searchTimeout); @@ -783,8 +736,8 @@

Most Popular