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:
1 parent
aa16a9276b
commit
d8bd93374f
5 files changed
+84
-68
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in new issue
Block a user