fix(search): resolve notification loops causing multiple API requests

- Make auth_token() public for catalog/search endpoint authentication
- Fix setSearchQuery to skip notification when value unchanged
- Fix setSearchState to only notify listeners when query actually changed
- Add title case transformation for better most_popular API results
- Add stale result detection to ignore outdated search responses
- Preserve most_popular when API returns inconsistent results
- Clean up debug logging
This commit is contained in:
vicrodh committed 2026-02-01 02:41:55 -06:00
1 parent d8bd93374f
commit f699cf0e63
4 files changed
+90 -19

No files matched your search

+2 -2
View File
@@ -187,8 +187,8 @@ impl QobuzClient {
self.session.read().await.as_ref().map(|s| s.user_id)
}
/// Get user auth token header value
async fn auth_token(&self) -> Result<String> {
/// Get user auth token header value (public for catalog search)
pub async fn auth_token(&self) -> Result<String> {
self.session
.read()
.await
+25 -1
View File
@@ -75,14 +75,17 @@ pub async fn search_all(
query: String,
state: State<'_, AppState>,
) -> Result<SearchAllResults, String> {
log::debug!("search_all called with query: {}", query);
let client = state.client.lock().await;
// 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())?)
.header("X-User-Auth-Token", client.auth_token().await.map_err(|e| e.to_string())?)
.query(&[
("query", query.as_str()),
("limit", "30"),
@@ -119,7 +122,28 @@ pub async fn search_all(
.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());
.and_then(|item| {
let item_type = item.get("type")?.as_str()?;
let content = item.get("content")?;
log::debug!("most_popular type: {}, content keys: {:?}",
item_type,
content.as_object().map(|o| o.keys().collect::<Vec<_>>())
);
match item_type {
"tracks" => serde_json::from_value::<Track>(content.clone())
.ok()
.map(MostPopularItem::Tracks),
"albums" => serde_json::from_value::<Album>(content.clone())
.ok()
.map(MostPopularItem::Albums),
"artists" => serde_json::from_value::<Artist>(content.clone())
.ok()
.map(MostPopularItem::Artists),
_ => None,
}
});
Ok(SearchAllResults {
albums,
+57 -14
View File
@@ -213,6 +213,8 @@
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let isLoadingMore = $state(false);
let searchVersion = $state(0); // Track search version to ignore stale results
let currentSearchQuery = $state(''); // Track current in-flight search
const PAGE_SIZE = 20;
// Check if there are more results to load
@@ -313,7 +315,19 @@
});
async function performSearch() {
if (!query.trim()) return;
const searchQuery = query.trim();
if (!searchQuery) return;
// Prevent duplicate concurrent searches for the same query
if (currentSearchQuery === searchQuery && isSearching) {
console.log(`Skipping duplicate search for "${searchQuery}"`);
return;
}
// Increment version to invalidate any in-flight requests
searchVersion++;
const thisSearchVersion = searchVersion;
currentSearchQuery = searchQuery;
isSearching = true;
searchError = null;
@@ -321,47 +335,71 @@
try {
// Search based on active tab - reset to first page
if (activeTab === 'all') {
allResults = await invoke<SearchAllResults<Album, Track, Artist>>('search_all', {
query: query.trim(),
searchType: filterType
// Use title case for better most_popular results from Qobuz API
const results = await invoke<SearchAllResults<Album, Track, Artist>>('search_all', {
query: toTitleCase(searchQuery)
});
console.log('All results:', allResults);
// Only apply if query hasn't changed while we were fetching
const currentQuery = query.trim();
if (currentQuery !== searchQuery) {
console.log(`[Search] Ignoring stale result for "${searchQuery}"`);
return;
}
// Preserve most_popular if new result doesn't have it but old one does
// (API can be inconsistent for the same query)
if (!results.most_popular && allResults?.most_popular) {
results.most_popular = allResults.most_popular;
}
allResults = results;
if (allResults && allResults.albums.items) {
await loadAllAlbumDownloadStatuses(allResults.albums.items);
}
} else if (activeTab === 'albums') {
albumResults = await invoke<SearchResults<Album>>('search_albums', {
query: query.trim(),
const results = await invoke<SearchResults<Album>>('search_albums', {
query: searchQuery,
limit: PAGE_SIZE,
offset: 0,
searchType: filterType
});
if (query.trim() !== searchQuery) return;
albumResults = results;
console.log('Album results:', albumResults);
if (albumResults && albumResults.items) {
await loadAllAlbumDownloadStatuses(albumResults.items);
}
} else if (activeTab === 'tracks') {
trackResults = await invoke<SearchResults<Track>>('search_tracks', {
query: query.trim(),
const results = await invoke<SearchResults<Track>>('search_tracks', {
query: searchQuery,
limit: PAGE_SIZE,
offset: 0,
searchType: filterType
});
if (query.trim() !== searchQuery) return;
trackResults = results;
console.log('Track results:', trackResults);
} else if (activeTab === 'artists') {
artistResults = await invoke<SearchResults<Artist>>('search_artists', {
query: query.trim(),
const results = await invoke<SearchResults<Artist>>('search_artists', {
query: searchQuery,
limit: PAGE_SIZE,
offset: 0,
searchType: filterType
});
if (query.trim() !== searchQuery) return;
artistResults = results;
console.log('Artist results:', artistResults);
}
} catch (err) {
console.error('Search error:', err);
searchError = String(err);
// Only show error if this is still the latest search
if (thisSearchVersion === searchVersion) {
console.error('Search error:', err);
searchError = String(err);
}
} finally {
isSearching = false;
// Only update loading state if this is still the latest search
if (thisSearchVersion === searchVersion) {
isSearching = false;
currentSearchQuery = '';
}
}
}
@@ -528,6 +566,11 @@
return artist.image?.large || artist.image?.thumbnail || artist.image?.small || '';
}
// Title case query for better API results (Qobuz returns most_popular more consistently with proper capitalization)
function toTitleCase(str: string): string {
return str.replace(/\b\w/g, char => char.toUpperCase());
}
function scrollAlbumsCarousel(direction: 'left' | 'right') {
if (direction === 'left') {
currentAlbumPage = Math.max(0, currentAlbumPage - 1);
+6 -2
View File
@@ -48,9 +48,12 @@ export function getSearchState<Album, Track, Artist>(): SearchState<Album, Track
}
export function setSearchState<Album, Track, Artist>(next: SearchState<Album, Track, Artist>): void {
const prevQuery = searchState.query;
searchState = next as SearchState<unknown, unknown, unknown>;
// Notify query listeners when query changes
queryListeners.forEach(fn => fn(searchState.query));
// Only notify query listeners when query actually changes
if (prevQuery !== searchState.query) {
queryListeners.forEach(fn => fn(searchState.query));
}
}
// Signal to trigger scroll-to-top and focus on search input
@@ -75,6 +78,7 @@ export function getSearchQuery(): string {
}
export function setSearchQuery(query: string): void {
if (searchState.query === query) return; // No change, don't notify
searchState.query = query;
queryListeners.forEach(fn => fn(query));
}