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
This commit is contained in:
vicrodh committed 2026-02-01 01:33:17 -06:00
1 parent aa16a9276b
commit d8bd93374f
5 files changed
+84 -68

No files matched your search

+7 -2
View File
@@ -73,8 +73,8 @@ impl QobuzClient {
self.locale.read().await.clone() self.locale.read().await.clone()
} }
/// Get app ID /// Get app ID (public for catalog search)
async fn app_id(&self) -> Result<String> { pub async fn app_id(&self) -> Result<String> {
self.tokens self.tokens
.read() .read()
.await .await
@@ -83,6 +83,11 @@ impl QobuzClient {
.ok_or_else(|| ApiError::BundleExtractionError("Client not initialized".to_string())) .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) /// Get validated secret (validates on first use)
async fn secret(&self) -> Result<String> { async fn secret(&self) -> Result<String> {
// Check if we already have a validated secret // Check if we already have a validated secret
+3
View File
@@ -39,6 +39,9 @@ pub mod paths {
// Label // Label
pub const LABEL_GET: &str = "/label/get"; pub const LABEL_GET: &str = "/label/get";
// Catalog (combined search)
pub const CATALOG_SEARCH: &str = "/catalog/search";
} }
/// Build full URL for an endpoint /// Build full URL for an endpoint
+59 -11
View File
@@ -2,16 +2,27 @@
use tauri::State; 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::api_cache::ApiCacheState;
use crate::AppState; use crate::AppState;
use serde::{Deserialize, Serialize}; 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchAllResults { pub struct SearchAllResults {
pub albums: SearchResultsPage<Album>, pub albums: SearchResultsPage<Album>,
pub tracks: SearchResultsPage<Track>, pub tracks: SearchResultsPage<Track>,
pub artists: SearchResultsPage<Artist>, pub artists: SearchResultsPage<Artist>,
pub most_popular: Option<MostPopularItem>,
} }
#[tauri::command] #[tauri::command]
@@ -62,22 +73,59 @@ pub async fn search_artists(
#[tauri::command] #[tauri::command]
pub async fn search_all( pub async fn search_all(
query: String, query: String,
search_type: Option<String>,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<SearchAllResults, String> { ) -> Result<SearchAllResults, String> {
let client = state.client.lock().await; let client = state.client.lock().await;
let st = search_type.as_deref();
let (albums_result, tracks_result, artists_result) = tokio::join!( // Use catalog/search endpoint which returns everything including most_popular
client.search_albums(&query, 30, 0, st), let url = endpoints::build_url(paths::CATALOG_SEARCH);
client.search_tracks(&query, 8, 0, st), let response: Value = client
client.search_artists(&query, 12, 0, st) .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<Album> = 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<Track> = 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<Artist> = 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<MostPopularItem> = 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 { Ok(SearchAllResults {
albums: albums_result.map_err(|e| e.to_string())?, albums,
tracks: tracks_result.map_err(|e| e.to_string())?, tracks,
artists: artists_result.map_err(|e| e.to_string())?, artists,
most_popular,
}) })
} }
+8 -55
View File
@@ -220,55 +220,8 @@
let hasMoreTracks = $derived(trackResults ? trackResults.offset + trackResults.items.length < trackResults.total : false); let hasMoreTracks = $derived(trackResults ? trackResults.offset + trackResults.items.length < trackResults.total : false);
let hasMoreArtists = $derived(artistResults ? artistResults.offset + artistResults.items.length < artistResults.total : false); let hasMoreArtists = $derived(artistResults ? artistResults.offset + artistResults.items.length < artistResults.total : false);
// Determine the "Most Popular" result based on query matching // Most popular result comes directly from Qobuz's catalog/search API
// Priority: exact match > starts with > contains > default to first artist // No heuristics needed - Qobuz determines the most relevant result
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;
});
function debounceSearch() { function debounceSearch() {
if (searchTimeout) clearTimeout(searchTimeout); if (searchTimeout) clearTimeout(searchTimeout);
@@ -783,8 +736,8 @@
<h3><Crown size={18} color="gold" /> Most Popular</h3> <h3><Crown size={18} color="gold" /> Most Popular</h3>
</div> </div>
<div class="most-popular-wrapper"> <div class="most-popular-wrapper">
{#if mostPopularResult?.type === 'artist' && mostPopularResult.artist} {#if allResults.most_popular?.type === 'artists'}
{@const artist = mostPopularResult.artist} {@const artist = allResults.most_popular.content}
<button class="artist-card most-popular-card" onclick={() => onArtistClick?.(artist.id)}> <button class="artist-card most-popular-card" onclick={() => onArtistClick?.(artist.id)}>
<div class="artist-image-wrapper"> <div class="artist-image-wrapper">
<div class="artist-image-placeholder"> <div class="artist-image-placeholder">
@@ -806,8 +759,8 @@
<div class="artist-albums">{$t('library.albumCount', { values: { count: artist.albums_count } })}</div> <div class="artist-albums">{$t('library.albumCount', { values: { count: artist.albums_count } })}</div>
{/if} {/if}
</button> </button>
{:else if mostPopularResult?.type === 'album' && mostPopularResult.album} {:else if allResults.most_popular?.type === 'albums'}
{@const album = mostPopularResult.album} {@const album = allResults.most_popular.content}
<AlbumCard <AlbumCard
albumId={album.id} albumId={album.id}
artwork={getAlbumArtwork(album)} artwork={getAlbumArtwork(album)}
@@ -830,8 +783,8 @@
{downloadStateVersion} {downloadStateVersion}
onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }} onclick={() => { onAlbumClick?.(album.id); loadAlbumDownloadStatus(album.id); }}
/> />
{:else if mostPopularResult?.type === 'track' && mostPopularResult.track} {:else if allResults.most_popular?.type === 'tracks'}
{@const track = mostPopularResult.track} {@const track = allResults.most_popular.content}
<button class="track-card most-popular-card" onclick={() => handleSearchTrackPlay(track, 0)}> <button class="track-card most-popular-card" onclick={() => handleSearchTrackPlay(track, 0)}>
<div class="track-card-artwork"> <div class="track-card-artwork">
{#if track.album?.image?.large || track.album?.image?.small} {#if track.album?.image?.large || track.album?.image?.small}
+7
View File
@@ -10,10 +10,17 @@ export interface SearchResults<T> {
limit: number; limit: number;
} }
// Most popular item from Qobuz catalog search
export type MostPopularItem<Album, Track, Artist> =
| { type: 'albums'; content: Album }
| { type: 'tracks'; content: Track }
| { type: 'artists'; content: Artist };
export interface SearchAllResults<Album, Track, Artist> { export interface SearchAllResults<Album, Track, Artist> {
albums: SearchResults<Album>; albums: SearchResults<Album>;
tracks: SearchResults<Track>; tracks: SearchResults<Track>;
artists: SearchResults<Artist>; artists: SearchResults<Artist>;
most_popular: MostPopularItem<Album, Track, Artist> | null;
} }
export interface SearchState<Album, Track, Artist> { export interface SearchState<Album, Track, Artist> {