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:
1 parent
d8bd93374f
commit
f699cf0e63
4 files changed
+90
-19
No files matched your search
@@ -187,8 +187,8 @@ impl QobuzClient {
|
|||||||
self.session.read().await.as_ref().map(|s| s.user_id)
|
self.session.read().await.as_ref().map(|s| s.user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get user auth token header value
|
/// Get user auth token header value (public for catalog search)
|
||||||
async fn auth_token(&self) -> Result<String> {
|
pub async fn auth_token(&self) -> Result<String> {
|
||||||
self.session
|
self.session
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -75,14 +75,17 @@ pub async fn search_all(
|
|||||||
query: String,
|
query: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<SearchAllResults, String> {
|
) -> Result<SearchAllResults, String> {
|
||||||
|
log::debug!("search_all called with query: {}", query);
|
||||||
let client = state.client.lock().await;
|
let client = state.client.lock().await;
|
||||||
|
|
||||||
// Use catalog/search endpoint which returns everything including most_popular
|
// Use catalog/search endpoint which returns everything including most_popular
|
||||||
let url = endpoints::build_url(paths::CATALOG_SEARCH);
|
let url = endpoints::build_url(paths::CATALOG_SEARCH);
|
||||||
|
|
||||||
let response: Value = client
|
let response: Value = client
|
||||||
.get_http()
|
.get_http()
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.header("X-App-Id", client.app_id().await.map_err(|e| e.to_string())?)
|
.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", query.as_str()),
|
("query", query.as_str()),
|
||||||
("limit", "30"),
|
("limit", "30"),
|
||||||
@@ -119,7 +122,28 @@ pub async fn search_all(
|
|||||||
.and_then(|mp| mp.get("items"))
|
.and_then(|mp| mp.get("items"))
|
||||||
.and_then(|items| items.as_array())
|
.and_then(|items| items.as_array())
|
||||||
.and_then(|arr| arr.first())
|
.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 {
|
Ok(SearchAllResults {
|
||||||
albums,
|
albums,
|
||||||
|
|||||||
@@ -213,6 +213,8 @@
|
|||||||
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let isLoadingMore = $state(false);
|
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;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
// Check if there are more results to load
|
// Check if there are more results to load
|
||||||
@@ -313,7 +315,19 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function performSearch() {
|
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;
|
isSearching = true;
|
||||||
searchError = null;
|
searchError = null;
|
||||||
@@ -321,47 +335,71 @@
|
|||||||
try {
|
try {
|
||||||
// Search based on active tab - reset to first page
|
// Search based on active tab - reset to first page
|
||||||
if (activeTab === 'all') {
|
if (activeTab === 'all') {
|
||||||
allResults = await invoke<SearchAllResults<Album, Track, Artist>>('search_all', {
|
// Use title case for better most_popular results from Qobuz API
|
||||||
query: query.trim(),
|
const results = await invoke<SearchAllResults<Album, Track, Artist>>('search_all', {
|
||||||
searchType: filterType
|
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) {
|
if (allResults && allResults.albums.items) {
|
||||||
await loadAllAlbumDownloadStatuses(allResults.albums.items);
|
await loadAllAlbumDownloadStatuses(allResults.albums.items);
|
||||||
}
|
}
|
||||||
} else if (activeTab === 'albums') {
|
} else if (activeTab === 'albums') {
|
||||||
albumResults = await invoke<SearchResults<Album>>('search_albums', {
|
const results = await invoke<SearchResults<Album>>('search_albums', {
|
||||||
query: query.trim(),
|
query: searchQuery,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
searchType: filterType
|
searchType: filterType
|
||||||
});
|
});
|
||||||
|
if (query.trim() !== searchQuery) return;
|
||||||
|
albumResults = results;
|
||||||
console.log('Album results:', albumResults);
|
console.log('Album results:', albumResults);
|
||||||
if (albumResults && albumResults.items) {
|
if (albumResults && albumResults.items) {
|
||||||
await loadAllAlbumDownloadStatuses(albumResults.items);
|
await loadAllAlbumDownloadStatuses(albumResults.items);
|
||||||
}
|
}
|
||||||
} else if (activeTab === 'tracks') {
|
} else if (activeTab === 'tracks') {
|
||||||
trackResults = await invoke<SearchResults<Track>>('search_tracks', {
|
const results = await invoke<SearchResults<Track>>('search_tracks', {
|
||||||
query: query.trim(),
|
query: searchQuery,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
searchType: filterType
|
searchType: filterType
|
||||||
});
|
});
|
||||||
|
if (query.trim() !== searchQuery) return;
|
||||||
|
trackResults = results;
|
||||||
console.log('Track results:', trackResults);
|
console.log('Track results:', trackResults);
|
||||||
} else if (activeTab === 'artists') {
|
} else if (activeTab === 'artists') {
|
||||||
artistResults = await invoke<SearchResults<Artist>>('search_artists', {
|
const results = await invoke<SearchResults<Artist>>('search_artists', {
|
||||||
query: query.trim(),
|
query: searchQuery,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
searchType: filterType
|
searchType: filterType
|
||||||
});
|
});
|
||||||
|
if (query.trim() !== searchQuery) return;
|
||||||
|
artistResults = results;
|
||||||
console.log('Artist results:', artistResults);
|
console.log('Artist results:', artistResults);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Search error:', err);
|
// Only show error if this is still the latest search
|
||||||
searchError = String(err);
|
if (thisSearchVersion === searchVersion) {
|
||||||
|
console.error('Search error:', err);
|
||||||
|
searchError = String(err);
|
||||||
|
}
|
||||||
} finally {
|
} 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 || '';
|
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') {
|
function scrollAlbumsCarousel(direction: 'left' | 'right') {
|
||||||
if (direction === 'left') {
|
if (direction === 'left') {
|
||||||
currentAlbumPage = Math.max(0, currentAlbumPage - 1);
|
currentAlbumPage = Math.max(0, currentAlbumPage - 1);
|
||||||
|
|||||||
@@ -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 {
|
export function setSearchState<Album, Track, Artist>(next: SearchState<Album, Track, Artist>): void {
|
||||||
|
const prevQuery = searchState.query;
|
||||||
searchState = next as SearchState<unknown, unknown, unknown>;
|
searchState = next as SearchState<unknown, unknown, unknown>;
|
||||||
// Notify query listeners when query changes
|
// Only notify query listeners when query actually changes
|
||||||
queryListeners.forEach(fn => fn(searchState.query));
|
if (prevQuery !== searchState.query) {
|
||||||
|
queryListeners.forEach(fn => fn(searchState.query));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signal to trigger scroll-to-top and focus on search input
|
// 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 {
|
export function setSearchQuery(query: string): void {
|
||||||
|
if (searchState.query === query) return; // No change, don't notify
|
||||||
searchState.query = query;
|
searchState.query = query;
|
||||||
queryListeners.forEach(fn => fn(query));
|
queryListeners.forEach(fn => fn(query));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user