feat(award): resolve award id by name via /award/explore when /album/get omits it

/album/get's AwardDto has id: String? — Qobuz sometimes returns an
award entry with only a name and no id. Rather than disabling the
click or toasting, we look up the id in the Qobuz award catalog.

- Adds v2_get_award_explore (paginated /award/explore) all the way
  through qbz-qobuz → core → bridge → command → invoke_handler.
- New frontend awardCatalogStore caches the full (id, name) list
  lazily, one fetch per session, resolved on first click that needs
  it. Further clicks are instant.
- handleAwardClick goes straight to navigation when id is present,
  falls back to resolveAwardIdByName otherwise. Toast only fires if
  the name isn't in the catalog either (genuinely unknown award).

cc
This commit is contained in:
vicrodh committed 2026-04-14 15:06:15 -06:00
1 parent 9460838223
commit 854eaaa0a4
8 files changed
+176 -7

No files matched your search

+14
View File
@@ -851,6 +851,20 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
.map_err(CoreError::Api)
}
/// Enumerate award catalog (/award/explore).
pub async fn get_award_explore(
&self,
limit: u32,
offset: u32,
) -> Result<serde_json::Value, CoreError> {
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,
+24
View File
@@ -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<serde_json::Value> {
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.
+1
View File
@@ -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";
+25
View File
@@ -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<u32>,
offset: Option<u32>,
bridge: State<'_, CoreBridgeState>,
runtime: State<'_, RuntimeManagerState>,
) -> Result<serde_json::Value, RuntimeError> {
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)]
+12
View File
@@ -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<serde_json::Value, String> {
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<qbz_models::AwardPageData, String> {
self.core
+1
View File
@@ -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,
+84
View File
@@ -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<string, string>();
let loaded = false;
let inflight: Promise<void> | null = null;
function normalize(name: string): string {
return name.trim().toLowerCase();
}
async function loadCatalog(): Promise<void> {
if (loaded) return;
if (inflight) return inflight;
inflight = (async () => {
const next = new Map<string, string>();
let offset = 0;
while (true) {
try {
const result = await invoke<ExploreResponse>('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<string | null> {
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;
}
+15 -7
View File
@@ -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);
}
/**