Files
qbz/src-tauri/src/commands/search.rs
T
vicrodh f699cf0e63 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
2026-02-01 02:41:55 -06:00

366 lines
11 KiB
Rust

//! Search commands
use tauri::State;
use crate::api::{endpoints, endpoints::paths, Album, Artist, ArtistAlbums, LabelDetail, SearchResultsPage, Track};
use crate::api_cache::ApiCacheState;
use crate::AppState;
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)]
pub struct SearchAllResults {
pub albums: SearchResultsPage<Album>,
pub tracks: SearchResultsPage<Track>,
pub artists: SearchResultsPage<Artist>,
pub most_popular: Option<MostPopularItem>,
}
#[tauri::command]
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_type.as_deref())
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
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_type.as_deref())
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
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_type.as_deref())
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
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"),
("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| {
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,
tracks,
artists,
most_popular,
})
}
#[tauri::command]
pub async fn get_album(
album_id: String,
state: State<'_, AppState>,
cache_state: State<'_, ApiCacheState>,
) -> Result<Album, String> {
// Check cache first
{
let cache = cache_state.cache.lock().await;
if let Some(cached_data) = cache.get_album(&album_id, None)? {
log::debug!("Cache hit for album {}", album_id);
return serde_json::from_str(&cached_data)
.map_err(|e| format!("Failed to parse cached album: {}", e));
}
}
// Cache miss - fetch from API
log::debug!("Cache miss for album {}, fetching from API", album_id);
let client = state.client.lock().await;
let album = client.get_album(&album_id).await.map_err(|e| e.to_string())?;
// Cache the result
{
let cache = cache_state.cache.lock().await;
let json = serde_json::to_string(&album)
.map_err(|e| format!("Failed to serialize album: {}", e))?;
cache.set_album(&album_id, &json)?;
log::debug!("Cached album {}", album_id);
}
Ok(album)
}
/// Get featured albums by type (new-releases, press-awards)
#[tauri::command]
pub async fn get_featured_albums(
featured_type: String,
limit: Option<u32>,
offset: Option<u32>,
state: State<'_, AppState>,
) -> Result<SearchResultsPage<Album>, String> {
let client = state.client.lock().await;
client
.get_featured_albums(&featured_type, limit.unwrap_or(12), offset.unwrap_or(0))
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_track(
track_id: u64,
state: State<'_, AppState>,
cache_state: State<'_, ApiCacheState>,
) -> Result<Track, String> {
// Check cache first
{
let cache = cache_state.cache.lock().await;
if let Some(cached_data) = cache.get_track(track_id, None)? {
log::debug!("Cache hit for track {}", track_id);
return serde_json::from_str(&cached_data)
.map_err(|e| format!("Failed to parse cached track: {}", e));
}
}
// Cache miss - fetch from API
log::debug!("Cache miss for track {}, fetching from API", track_id);
let client = state.client.lock().await;
let track = client.get_track(track_id).await.map_err(|e| e.to_string())?;
// Cache the result
{
let cache = cache_state.cache.lock().await;
let json = serde_json::to_string(&track)
.map_err(|e| format!("Failed to serialize track: {}", e))?;
cache.set_track(track_id, &json)?;
}
Ok(track)
}
/// Get artist with albums
#[tauri::command]
pub async fn get_artist(
artist_id: u64,
state: State<'_, AppState>,
cache_state: State<'_, ApiCacheState>,
) -> Result<Artist, String> {
log::info!("Command: get_artist {}", artist_id);
// Get current locale
let locale = {
let client = state.client.lock().await;
client.get_locale().await
};
// Check cache first
{
let cache = cache_state.cache.lock().await;
if let Some(cached_data) = cache.get_artist(artist_id, &locale, None)? {
log::debug!("Cache hit for artist {} (locale: {})", artist_id, locale);
return serde_json::from_str(&cached_data)
.map_err(|e| format!("Failed to parse cached artist: {}", e));
}
}
// Cache miss - fetch from API
log::debug!("Cache miss for artist {} (locale: {}), fetching from API", artist_id, locale);
let client = state.client.lock().await;
let artist = client
.get_artist(artist_id, true)
.await
.map_err(|e| e.to_string())?;
// Cache the result
{
let cache = cache_state.cache.lock().await;
let json = serde_json::to_string(&artist)
.map_err(|e| format!("Failed to serialize artist: {}", e))?;
cache.set_artist(artist_id, &locale, &json)?;
}
Ok(artist)
}
/// Get artist detail with albums, playlists, and appears-on tracks
/// Fetches 1000 albums initially to ensure each album type section
/// (Discography, EPs, Live, etc.) has enough albums loaded
#[tauri::command]
pub async fn get_artist_detail(
artist_id: u64,
state: State<'_, AppState>,
) -> Result<Artist, String> {
log::info!("Command: get_artist_detail {}", artist_id);
let client = state.client.lock().await;
client
.get_artist_detail(artist_id, Some(1000), Some(0))
.await
.map_err(|e| e.to_string())
}
/// Get artist albums with pagination (for load more)
#[tauri::command]
pub async fn get_artist_albums(
artist_id: u64,
limit: Option<u32>,
offset: Option<u32>,
state: State<'_, AppState>,
) -> Result<ArtistAlbums, String> {
log::info!(
"Command: get_artist_albums {} limit={:?} offset={:?}",
artist_id,
limit,
offset
);
let client = state.client.lock().await;
let artist = client
.get_artist_with_pagination(artist_id, true, limit, offset)
.await
.map_err(|e| e.to_string())?;
artist
.albums
.ok_or_else(|| "No albums in response".to_string())
}
/// Get similar artists for an artist ID
#[tauri::command]
pub async fn get_similar_artists(
artist_id: u64,
limit: Option<u32>,
offset: Option<u32>,
state: State<'_, AppState>,
) -> Result<SearchResultsPage<Artist>, String> {
log::info!(
"Command: get_similar_artists {} limit={:?} offset={:?}",
artist_id,
limit,
offset
);
let client = state.client.lock().await;
client
.get_similar_artists(artist_id, limit.unwrap_or(5), offset.unwrap_or(0))
.await
.map_err(|e| e.to_string())
}
/// Get label detail with albums
#[tauri::command]
pub async fn get_label(
label_id: u64,
limit: Option<u32>,
offset: Option<u32>,
state: State<'_, AppState>,
) -> Result<LabelDetail, String> {
log::info!(
"Command: get_label {} limit={:?} offset={:?}",
label_id,
limit,
offset
);
let client = state.client.lock().await;
client
.get_label(label_id, limit.unwrap_or(100), offset.unwrap_or(0))
.await
.map_err(|e| e.to_string())
}