diff --git a/crates/qbz-core/src/core.rs b/crates/qbz-core/src/core.rs index dfd541fb7..b83a5c306 100644 --- a/crates/qbz-core/src/core.rs +++ b/crates/qbz-core/src/core.rs @@ -851,6 +851,20 @@ impl QbzCore { .map_err(CoreError::Api) } + /// Enumerate award catalog (/award/explore). + pub async fn get_award_explore( + &self, + limit: u32, + offset: u32, + ) -> Result { + let client = self.client.read().await; + let client = client.as_ref().ok_or(CoreError::NotInitialized)?; + client + .get_award_explore(limit, offset) + .await + .map_err(CoreError::Api) + } + /// Get award page — hero info + award-winning releases. pub async fn get_award_page( &self, diff --git a/crates/qbz-qobuz/src/client.rs b/crates/qbz-qobuz/src/client.rs index 68c9763f5..2daa357f6 100644 --- a/crates/qbz-qobuz/src/client.rs +++ b/crates/qbz-qobuz/src/client.rs @@ -1477,6 +1477,30 @@ impl QobuzClient { Ok(serde_json::from_value(response)?) } + /// Enumerate the award catalog (/award/explore). Paginated. + /// Returns raw JSON items so callers can extract just (id, name). + pub async fn get_award_explore( + &self, + limit: u32, + offset: u32, + ) -> Result { + let url = endpoints::build_url(paths::AWARD_EXPLORE); + log::debug!("[API] get_award_explore(limit={}, offset={})", limit, offset); + let response: serde_json::Value = self + .signed_get_auth( + &url, + "awardexplore", + &[ + ("limit", limit.to_string()), + ("offset", offset.to_string()), + ], + ) + .await? + .json() + .await?; + Ok(response) + } + /// Get award page — hero info + categorized award-winning releases. /// Mirrors Android's AwardDto shape from /award/page. Uses auth /// headers (app_id + user token) — region-gated catalog. diff --git a/crates/qbz-qobuz/src/endpoints.rs b/crates/qbz-qobuz/src/endpoints.rs index a992cf249..6565f1ee0 100644 --- a/crates/qbz-qobuz/src/endpoints.rs +++ b/crates/qbz-qobuz/src/endpoints.rs @@ -60,6 +60,7 @@ pub mod paths { // Award pub const AWARD_PAGE: &str = "/award/page"; pub const AWARD_GET_ALBUMS: &str = "/award/getAlbums"; + pub const AWARD_EXPLORE: &str = "/award/explore"; // Genre pub const GENRE_LIST: &str = "/genre/list"; diff --git a/src-tauri/src/commands_v2/catalog.rs b/src-tauri/src/commands_v2/catalog.rs index a5e492816..2c265ee1f 100644 --- a/src-tauri/src/commands_v2/catalog.rs +++ b/src-tauri/src/commands_v2/catalog.rs @@ -442,6 +442,31 @@ pub async fn v2_get_label_page( .map_err(RuntimeError::Internal) } +/// Enumerate award catalog — paginated list of awards with their ids +/// and names. Used by the frontend to resolve award ids by name when +/// /album/get omits them. +#[tauri::command] +pub async fn v2_get_award_explore( + limit: Option, + offset: Option, + bridge: State<'_, CoreBridgeState>, + runtime: State<'_, RuntimeManagerState>, +) -> Result { + runtime + .manager() + .check_requirements(CommandRequirement::RequiresCoreBridgeAuth) + .await?; + + let limit = limit.unwrap_or(100); + let offset = offset.unwrap_or(0); + log::info!("[V2] get_award_explore: limit={} offset={}", limit, offset); + let bridge = bridge.get().await; + bridge + .get_award_explore(limit, offset) + .await + .map_err(RuntimeError::Internal) +} + /// Get award page (hero info + award-winning releases). #[tauri::command] #[allow(non_snake_case)] diff --git a/src-tauri/src/core_bridge.rs b/src-tauri/src/core_bridge.rs index d57186d69..d9291ba36 100644 --- a/src-tauri/src/core_bridge.rs +++ b/src-tauri/src/core_bridge.rs @@ -570,6 +570,18 @@ impl CoreBridge { .map_err(|e| e.to_string()) } + /// Enumerate award catalog (/award/explore). + pub async fn get_award_explore( + &self, + limit: u32, + offset: u32, + ) -> Result { + self.core + .get_award_explore(limit, offset) + .await + .map_err(|e| e.to_string()) + } + /// Get award page — hero info + award-winning releases. pub async fn get_award_page(&self, award_id: &str) -> Result { self.core diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6f6653bbd..09aaa770e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1603,6 +1603,7 @@ pub fn run() { commands_v2::v2_get_label_explore, commands_v2::v2_get_award_page, commands_v2::v2_get_award_albums, + commands_v2::v2_get_award_explore, commands_v2::v2_pause_playback, commands_v2::v2_resume_playback, commands_v2::v2_stop_playback, diff --git a/src/lib/stores/awardCatalogStore.ts b/src/lib/stores/awardCatalogStore.ts new file mode 100644 index 000000000..391d28400 --- /dev/null +++ b/src/lib/stores/awardCatalogStore.ts @@ -0,0 +1,84 @@ +/** + * Award catalog — resolves award ids by name for the cases where + * /album/get returns an award with only a name and no id. Fetched + * lazily via /award/explore (paginated), cached in memory per session. + */ + +import { invoke } from '@tauri-apps/api/core'; + +interface AwardCatalogEntry { + id: string; + name: string; +} + +interface ExploreResponse { + has_more?: boolean; + items?: Array<{ id?: string | number; name?: string }>; +} + +const PAGE_SIZE = 100; + +// name (lowercased, trimmed) → id +let byName = new Map(); +let loaded = false; +let inflight: Promise | null = null; + +function normalize(name: string): string { + return name.trim().toLowerCase(); +} + +async function loadCatalog(): Promise { + if (loaded) return; + if (inflight) return inflight; + + inflight = (async () => { + const next = new Map(); + let offset = 0; + while (true) { + try { + const result = await invoke('v2_get_award_explore', { + limit: PAGE_SIZE, + offset, + }); + const items = result.items ?? []; + for (const item of items) { + if (item?.id == null || !item?.name) continue; + next.set(normalize(item.name), String(item.id)); + } + if (items.length < PAGE_SIZE) break; + if (result.has_more === false) break; + offset += items.length; + // Reasonable upper bound to avoid infinite loops on a broken API. + if (offset >= 2000) break; + } catch (err) { + console.error('[AwardCatalog] explore failed:', err); + break; + } + } + byName = next; + loaded = true; + inflight = null; + })(); + + return inflight; +} + +/** + * Return the award id for a given name, fetching the catalog on + * first call. Resolves to null if not found. + */ +export async function resolveAwardIdByName(name: string): Promise { + if (!name) return null; + await loadCatalog(); + return byName.get(normalize(name)) ?? null; +} + +/** Synchronous lookup — returns null until loadCatalog has run. */ +export function lookupAwardIdByName(name: string): string | null { + if (!name) return null; + return byName.get(normalize(name)) ?? null; +} + +export function isCatalogLoaded(): boolean { + return loaded; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e908b9238..1e8914f95 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -155,6 +155,7 @@ import { loadArtistFavorites } from '$lib/stores/artistFavoritesStore'; import { loadLabelFavorites } from '$lib/stores/labelFavoritesStore'; import { loadAwardFavorites } from '$lib/stores/awardFavoritesStore'; + import { resolveAwardIdByName } from '$lib/stores/awardCatalogStore'; import { getDefaultFavoritesTab } from '$lib/utils/favorites'; import { platform } from '$lib/utils/platform'; import type { FavoritesPreferences, ResolvedMusician } from '$lib/types'; @@ -1528,16 +1529,23 @@ navigateTo('label-releases', labelId); } - function handleAwardClick(awardId: string, awardName: string) { - if (!awardId) { - // Some /album/get responses omit award.id. We still keep the - // click affordance so it looks interactive, but there is no - // target page to open — surface that to the user. + async function handleAwardClick(awardId: string, awardName: string) { + // Happy path: /album/get included the id — navigate immediately. + if (awardId) { + selectedAward = { id: awardId, name: awardName }; + navigateTo('award', awardId); + return; + } + // /album/get sometimes returns an award entry with only a name. + // Resolve via the cached /award/explore catalog. One HTTP round-trip + // the first time, instant afterwards. + const resolved = await resolveAwardIdByName(awardName); + if (!resolved) { showToast($t('toast.awardUnavailable'), 'info'); return; } - selectedAward = { id: awardId, name: awardName }; - navigateTo('award', awardId); + selectedAward = { id: resolved, name: awardName }; + navigateTo('award', resolved); } /**