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:
vicrodh committed 2026-01-25 12:04:49 -06:00
1 parent 6467ca038e
commit 2136a2a0e7
7 files changed
+268 -92

No files matched your search

+66 -18
View File
@@ -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(&params)
.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(&params)
.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(&params)
.send()
.await?
.json()
+12 -7
View File
@@ -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 {
+2 -2
View File
@@ -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))?;
+1 -1
View File
@@ -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()))?;
+181 -64
View File
@@ -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 {
+5
View File
@@ -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,
+1
View File
@@ -512,6 +512,7 @@
setSearchState({
query: name,
activeTab: 'all',
filterType: null,
albumResults: null,
trackResults: null,
artistResults: null,