feat(search): add search type filters for refined results
Backend: - Add search_type parameter to search_albums, search_tracks, search_artists - Pass optional type filter to Qobuz API (Performer, Composer, Label, ReleaseName) Frontend: - Add SearchFilterType to search state management - Add filter radio buttons in SearchView (visible when results exist) - Remove redundant counts from tabs - Add Clear filter button independent from Clear search - Filters reset automatically when starting new search or clearing Filters allow finding performers, composers, and labels that default search misses (e.g., searching "Nicko McBrain" with Performer filter shows Iron Maiden tracks)
This commit is contained in:
1 parent
6467ca038e
commit
2136a2a0e7
7 files changed
+268
-92
No files matched your search
+66
-18
@@ -195,17 +195,33 @@ impl QobuzClient {
|
||||
// === Search endpoints ===
|
||||
|
||||
/// Search for albums
|
||||
pub async fn search_albums(&self, query: &str, limit: u32, offset: u32) -> Result<SearchResultsPage<Album>> {
|
||||
/// Optional search_type: "MainArtist", "Performer", "Composer", "Label", "ReleaseName"
|
||||
pub async fn search_albums(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
search_type: Option<&str>,
|
||||
) -> Result<SearchResultsPage<Album>> {
|
||||
let url = endpoints::build_url(paths::ALBUM_SEARCH);
|
||||
let limit_str = limit.to_string();
|
||||
let offset_str = offset.to_string();
|
||||
|
||||
let mut params: Vec<(&str, &str)> = vec![
|
||||
("query", query),
|
||||
("limit", &limit_str),
|
||||
("offset", &offset_str),
|
||||
];
|
||||
|
||||
if let Some(st) = search_type {
|
||||
params.push(("type", st));
|
||||
}
|
||||
|
||||
let response: Value = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("X-App-Id", self.app_id().await?)
|
||||
.query(&[
|
||||
("query", query),
|
||||
("limit", &limit.to_string()),
|
||||
("offset", &offset.to_string()),
|
||||
])
|
||||
.query(¶ms)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
@@ -219,17 +235,33 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
/// Search for tracks
|
||||
pub async fn search_tracks(&self, query: &str, limit: u32, offset: u32) -> Result<SearchResultsPage<Track>> {
|
||||
/// Optional search_type: "MainArtist", "Performer", "Composer", "Label", "ReleaseName"
|
||||
pub async fn search_tracks(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
search_type: Option<&str>,
|
||||
) -> Result<SearchResultsPage<Track>> {
|
||||
let url = endpoints::build_url(paths::TRACK_SEARCH);
|
||||
let limit_str = limit.to_string();
|
||||
let offset_str = offset.to_string();
|
||||
|
||||
let mut params: Vec<(&str, &str)> = vec![
|
||||
("query", query),
|
||||
("limit", &limit_str),
|
||||
("offset", &offset_str),
|
||||
];
|
||||
|
||||
if let Some(st) = search_type {
|
||||
params.push(("type", st));
|
||||
}
|
||||
|
||||
let response: Value = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("X-App-Id", self.app_id().await?)
|
||||
.query(&[
|
||||
("query", query),
|
||||
("limit", &limit.to_string()),
|
||||
("offset", &offset.to_string()),
|
||||
])
|
||||
.query(¶ms)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
@@ -243,17 +275,33 @@ impl QobuzClient {
|
||||
}
|
||||
|
||||
/// Search for artists
|
||||
pub async fn search_artists(&self, query: &str, limit: u32, offset: u32) -> Result<SearchResultsPage<Artist>> {
|
||||
/// Optional search_type: "MainArtist", "Performer", "Composer", "Label", "ReleaseName"
|
||||
pub async fn search_artists(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
search_type: Option<&str>,
|
||||
) -> Result<SearchResultsPage<Artist>> {
|
||||
let url = endpoints::build_url(paths::ARTIST_SEARCH);
|
||||
let limit_str = limit.to_string();
|
||||
let offset_str = offset.to_string();
|
||||
|
||||
let mut params: Vec<(&str, &str)> = vec![
|
||||
("query", query),
|
||||
("limit", &limit_str),
|
||||
("offset", &offset_str),
|
||||
];
|
||||
|
||||
if let Some(st) = search_type {
|
||||
params.push(("type", st));
|
||||
}
|
||||
|
||||
let response: Value = self
|
||||
.http
|
||||
.get(&url)
|
||||
.header("X-App-Id", self.app_id().await?)
|
||||
.query(&[
|
||||
("query", query),
|
||||
("limit", &limit.to_string()),
|
||||
("offset", &offset.to_string()),
|
||||
])
|
||||
.query(¶ms)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
|
||||
@@ -19,11 +19,12 @@ pub async fn search_albums(
|
||||
query: String,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
search_type: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SearchResultsPage<Album>, String> {
|
||||
let client = state.client.lock().await;
|
||||
client
|
||||
.search_albums(&query, limit.unwrap_or(20), offset.unwrap_or(0))
|
||||
.search_albums(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -33,11 +34,12 @@ pub async fn search_tracks(
|
||||
query: String,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
search_type: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SearchResultsPage<Track>, String> {
|
||||
let client = state.client.lock().await;
|
||||
client
|
||||
.search_tracks(&query, limit.unwrap_or(20), offset.unwrap_or(0))
|
||||
.search_tracks(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -47,11 +49,12 @@ pub async fn search_artists(
|
||||
query: String,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
search_type: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SearchResultsPage<Artist>, String> {
|
||||
let client = state.client.lock().await;
|
||||
client
|
||||
.search_artists(&query, limit.unwrap_or(20), offset.unwrap_or(0))
|
||||
.search_artists(&query, limit.unwrap_or(20), offset.unwrap_or(0), search_type.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -59,14 +62,16 @@ pub async fn search_artists(
|
||||
#[tauri::command]
|
||||
pub async fn search_all(
|
||||
query: String,
|
||||
search_type: Option<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<SearchAllResults, String> {
|
||||
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),
|
||||
client.search_tracks(&query, 8, 0),
|
||||
client.search_artists(&query, 12, 0)
|
||||
client.search_albums(&query, 30, 0, st),
|
||||
client.search_tracks(&query, 8, 0, st),
|
||||
client.search_artists(&query, 12, 0, st)
|
||||
);
|
||||
|
||||
Ok(SearchAllResults {
|
||||
|
||||
@@ -329,7 +329,7 @@ async fn search_artist_tracks(
|
||||
) -> Result<Vec<u64>, String> {
|
||||
// Search for the artist
|
||||
let search_results = client
|
||||
.search_artists(artist_name, 5, 0)
|
||||
.search_artists(artist_name, 5, 0, None)
|
||||
.await
|
||||
.map_err(|e| format!("Search failed: {}", e))?;
|
||||
|
||||
@@ -352,7 +352,7 @@ async fn search_artist_tracks(
|
||||
|
||||
// Get the artist's top tracks by searching with artist name
|
||||
let track_results = client
|
||||
.search_tracks(artist_name, max_tracks as u32, 0)
|
||||
.search_tracks(artist_name, max_tracks as u32, 0, None)
|
||||
.await
|
||||
.map_err(|e| format!("Track search failed: {}", e))?;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ pub async fn match_tracks(
|
||||
for track in tracks {
|
||||
let query = format!("{} {}", track.artist, track.title);
|
||||
let search = client
|
||||
.search_tracks(&query, SEARCH_LIMIT, 0)
|
||||
.search_tracks(&query, SEARCH_LIMIT, 0, None)
|
||||
.await
|
||||
.map_err(|e| PlaylistImportError::Qobuz(e.to_string()))?;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { Search, Disc3, Music, Mic2, User, X, ChevronLeft, ChevronRight, Crown } from 'lucide-svelte';
|
||||
import AlbumCard from '../AlbumCard.svelte';
|
||||
import TrackMenu from '../TrackMenu.svelte';
|
||||
import { getSearchState, setSearchState, type SearchResults, type SearchAllResults, type SearchTab } from '$lib/stores/searchState';
|
||||
import { getSearchState, setSearchState, type SearchResults, type SearchAllResults, type SearchTab, type SearchFilterType } from '$lib/stores/searchState';
|
||||
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
|
||||
import { togglePlay } from '$lib/stores/playerStore';
|
||||
import { t } from '$lib/i18n';
|
||||
@@ -174,6 +174,7 @@
|
||||
|
||||
let query = $state(cachedState.query ?? '');
|
||||
let activeTab = $state<SearchTab>(cachedState.activeTab ?? 'all');
|
||||
let filterType = $state<SearchFilterType>(cachedState.filterType ?? null);
|
||||
let isSearching = $state(false);
|
||||
let searchError = $state<string | null>(null);
|
||||
|
||||
@@ -193,10 +194,13 @@
|
||||
|
||||
function debounceSearch() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout);
|
||||
// Reset filter when user types a new search
|
||||
filterType = null;
|
||||
if (query.trim().length < 2) {
|
||||
albumResults = null;
|
||||
trackResults = null;
|
||||
artistResults = null;
|
||||
allResults = null;
|
||||
return;
|
||||
}
|
||||
searchTimeout = setTimeout(() => performSearch(), 300);
|
||||
@@ -208,6 +212,7 @@
|
||||
searchTimeout = null;
|
||||
}
|
||||
query = '';
|
||||
filterType = null;
|
||||
searchError = null;
|
||||
isSearching = false;
|
||||
albumResults = null;
|
||||
@@ -231,6 +236,7 @@
|
||||
setSearchState<Album, Track, Artist>({
|
||||
query,
|
||||
activeTab,
|
||||
filterType,
|
||||
albumResults,
|
||||
trackResults,
|
||||
artistResults,
|
||||
@@ -281,7 +287,8 @@
|
||||
// Search based on active tab - reset to first page
|
||||
if (activeTab === 'all') {
|
||||
allResults = await invoke<SearchAllResults<Album, Track, Artist>>('search_all', {
|
||||
query: query.trim()
|
||||
query: query.trim(),
|
||||
searchType: filterType
|
||||
});
|
||||
console.log('All results:', allResults);
|
||||
if (allResults && allResults.albums.items) {
|
||||
@@ -291,7 +298,8 @@
|
||||
albumResults = await invoke<SearchResults<Album>>('search_albums', {
|
||||
query: query.trim(),
|
||||
limit: PAGE_SIZE,
|
||||
offset: 0
|
||||
offset: 0,
|
||||
searchType: filterType
|
||||
});
|
||||
console.log('Album results:', albumResults);
|
||||
if (albumResults && albumResults.items) {
|
||||
@@ -301,14 +309,16 @@
|
||||
trackResults = await invoke<SearchResults<Track>>('search_tracks', {
|
||||
query: query.trim(),
|
||||
limit: PAGE_SIZE,
|
||||
offset: 0
|
||||
offset: 0,
|
||||
searchType: filterType
|
||||
});
|
||||
console.log('Track results:', trackResults);
|
||||
} else if (activeTab === 'artists') {
|
||||
artistResults = await invoke<SearchResults<Artist>>('search_artists', {
|
||||
query: query.trim(),
|
||||
limit: PAGE_SIZE,
|
||||
offset: 0
|
||||
offset: 0,
|
||||
searchType: filterType
|
||||
});
|
||||
console.log('Artist results:', artistResults);
|
||||
}
|
||||
@@ -331,7 +341,8 @@
|
||||
const moreResults = await invoke<SearchResults<Album>>('search_albums', {
|
||||
query: query.trim(),
|
||||
limit: PAGE_SIZE,
|
||||
offset: newOffset
|
||||
offset: newOffset,
|
||||
searchType: filterType
|
||||
});
|
||||
await loadAllAlbumDownloadStatuses(moreResults.items);
|
||||
albumResults = {
|
||||
@@ -344,7 +355,8 @@
|
||||
const moreResults = await invoke<SearchResults<Track>>('search_tracks', {
|
||||
query: query.trim(),
|
||||
limit: PAGE_SIZE,
|
||||
offset: newOffset
|
||||
offset: newOffset,
|
||||
searchType: filterType
|
||||
});
|
||||
trackResults = {
|
||||
...moreResults,
|
||||
@@ -356,7 +368,8 @@
|
||||
const moreResults = await invoke<SearchResults<Artist>>('search_artists', {
|
||||
query: query.trim(),
|
||||
limit: PAGE_SIZE,
|
||||
offset: newOffset
|
||||
offset: newOffset,
|
||||
searchType: filterType
|
||||
});
|
||||
artistResults = {
|
||||
...moreResults,
|
||||
@@ -378,6 +391,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Filter functions
|
||||
function setFilter(type: SearchFilterType) {
|
||||
filterType = type;
|
||||
if (query.trim().length >= 2) {
|
||||
performSearch();
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilter() {
|
||||
filterType = null;
|
||||
if (query.trim().length >= 2) {
|
||||
performSearch();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have any results to show filters
|
||||
let hasResults = $derived(
|
||||
!!(allResults || albumResults || trackResults || artistResults)
|
||||
);
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
@@ -532,55 +565,89 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'all'}
|
||||
onclick={() => handleTabChange('all')}
|
||||
>
|
||||
<Search size={18} />
|
||||
<span>All</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'albums'}
|
||||
onclick={() => handleTabChange('albums')}
|
||||
>
|
||||
<Disc3 size={18} />
|
||||
<span>{$t('search.albums')}</span>
|
||||
{#if albumResults}
|
||||
<span class="count">{albumResults.total}</span>
|
||||
{:else if allResults}
|
||||
<span class="count">{allResults.albums.total}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'tracks'}
|
||||
onclick={() => handleTabChange('tracks')}
|
||||
>
|
||||
<Music size={18} />
|
||||
<span>{$t('search.tracks')}</span>
|
||||
{#if trackResults}
|
||||
<span class="count">{trackResults.total}</span>
|
||||
{:else if allResults}
|
||||
<span class="count">{allResults.tracks.total}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'artists'}
|
||||
onclick={() => handleTabChange('artists')}
|
||||
>
|
||||
<Mic2 size={18} />
|
||||
<span>{$t('search.artists')}</span>
|
||||
{#if artistResults}
|
||||
<span class="count">{artistResults.total}</span>
|
||||
{:else if allResults}
|
||||
<span class="count">{allResults.artists.total}</span>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Tabs and Filters Row -->
|
||||
<div class="tabs-row">
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'all'}
|
||||
onclick={() => handleTabChange('all')}
|
||||
>
|
||||
<Search size={18} />
|
||||
<span>All</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'albums'}
|
||||
onclick={() => handleTabChange('albums')}
|
||||
>
|
||||
<Disc3 size={18} />
|
||||
<span>{$t('search.albums')}</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'tracks'}
|
||||
onclick={() => handleTabChange('tracks')}
|
||||
>
|
||||
<Music size={18} />
|
||||
<span>{$t('search.tracks')}</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'artists'}
|
||||
onclick={() => handleTabChange('artists')}
|
||||
>
|
||||
<Mic2 size={18} />
|
||||
<span>{$t('search.artists')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if hasResults}
|
||||
<div class="filters">
|
||||
<label class="filter-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="searchFilter"
|
||||
checked={filterType === 'Performer'}
|
||||
onchange={() => setFilter('Performer')}
|
||||
/>
|
||||
<span>Performer</span>
|
||||
</label>
|
||||
<label class="filter-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="searchFilter"
|
||||
checked={filterType === 'Composer'}
|
||||
onchange={() => setFilter('Composer')}
|
||||
/>
|
||||
<span>Composer</span>
|
||||
</label>
|
||||
<label class="filter-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="searchFilter"
|
||||
checked={filterType === 'Label'}
|
||||
onchange={() => setFilter('Label')}
|
||||
/>
|
||||
<span>Label</span>
|
||||
</label>
|
||||
<label class="filter-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="searchFilter"
|
||||
checked={filterType === 'ReleaseName'}
|
||||
onchange={() => setFilter('ReleaseName')}
|
||||
/>
|
||||
<span>Release</span>
|
||||
</label>
|
||||
|
||||
{#if filterType}
|
||||
<button class="clear-filter-btn" onclick={clearFilter} title="Clear filter">
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
@@ -1176,14 +1243,21 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
.tabs-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1209,11 +1283,54 @@
|
||||
border-bottom-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tab .count {
|
||||
padding: 2px 8px;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
/* Filters */
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filter-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.filter-option input[type="radio"] {
|
||||
accent-color: var(--accent-primary);
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-option:has(input:checked) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-option:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.clear-filter-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
|
||||
.clear-filter-btn:hover {
|
||||
background: var(--bg-quaternary, var(--bg-tertiary));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.results {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export type SearchTab = 'all' | 'albums' | 'tracks' | 'artists';
|
||||
|
||||
// Search filter types supported by Qobuz API
|
||||
export type SearchFilterType = 'MainArtist' | 'Performer' | 'Composer' | 'Label' | 'ReleaseName' | null;
|
||||
|
||||
export interface SearchResults<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
@@ -16,6 +19,7 @@ export interface SearchAllResults<Album, Track, Artist> {
|
||||
export interface SearchState<Album, Track, Artist> {
|
||||
query: string;
|
||||
activeTab: SearchTab;
|
||||
filterType: SearchFilterType;
|
||||
albumResults: SearchResults<Album> | null;
|
||||
trackResults: SearchResults<Track> | null;
|
||||
artistResults: SearchResults<Artist> | null;
|
||||
@@ -25,6 +29,7 @@ export interface SearchState<Album, Track, Artist> {
|
||||
let searchState: SearchState<unknown, unknown, unknown> = {
|
||||
query: '',
|
||||
activeTab: 'all',
|
||||
filterType: null,
|
||||
albumResults: null,
|
||||
trackResults: null,
|
||||
artistResults: null,
|
||||
|
||||
@@ -512,6 +512,7 @@
|
||||
setSearchState({
|
||||
query: name,
|
||||
activeTab: 'all',
|
||||
filterType: null,
|
||||
albumResults: null,
|
||||
trackResults: null,
|
||||
artistResults: null,
|
||||
|
||||
Reference in new issue
Block a user