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:
8 files changed
+176
-7
No files matched your search
@@ -851,6 +851,20 @@ impl<A: FrontendAdapter + Send + Sync + 'static> QbzCore<A> {
|
|||||||
.map_err(CoreError::Api)
|
.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.
|
/// Get award page — hero info + award-winning releases.
|
||||||
pub async fn get_award_page(
|
pub async fn get_award_page(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -1477,6 +1477,30 @@ impl QobuzClient {
|
|||||||
Ok(serde_json::from_value(response)?)
|
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.
|
/// Get award page — hero info + categorized award-winning releases.
|
||||||
/// Mirrors Android's AwardDto shape from /award/page. Uses auth
|
/// Mirrors Android's AwardDto shape from /award/page. Uses auth
|
||||||
/// headers (app_id + user token) — region-gated catalog.
|
/// headers (app_id + user token) — region-gated catalog.
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ pub mod paths {
|
|||||||
// Award
|
// Award
|
||||||
pub const AWARD_PAGE: &str = "/award/page";
|
pub const AWARD_PAGE: &str = "/award/page";
|
||||||
pub const AWARD_GET_ALBUMS: &str = "/award/getAlbums";
|
pub const AWARD_GET_ALBUMS: &str = "/award/getAlbums";
|
||||||
|
pub const AWARD_EXPLORE: &str = "/award/explore";
|
||||||
|
|
||||||
// Genre
|
// Genre
|
||||||
pub const GENRE_LIST: &str = "/genre/list";
|
pub const GENRE_LIST: &str = "/genre/list";
|
||||||
|
|||||||
@@ -442,6 +442,31 @@ pub async fn v2_get_label_page(
|
|||||||
.map_err(RuntimeError::Internal)
|
.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).
|
/// Get award page (hero info + award-winning releases).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
|
|||||||
@@ -570,6 +570,18 @@ impl CoreBridge {
|
|||||||
.map_err(|e| e.to_string())
|
.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.
|
/// Get award page — hero info + award-winning releases.
|
||||||
pub async fn get_award_page(&self, award_id: &str) -> Result<qbz_models::AwardPageData, String> {
|
pub async fn get_award_page(&self, award_id: &str) -> Result<qbz_models::AwardPageData, String> {
|
||||||
self.core
|
self.core
|
||||||
|
|||||||
@@ -1603,6 +1603,7 @@ pub fn run() {
|
|||||||
commands_v2::v2_get_label_explore,
|
commands_v2::v2_get_label_explore,
|
||||||
commands_v2::v2_get_award_page,
|
commands_v2::v2_get_award_page,
|
||||||
commands_v2::v2_get_award_albums,
|
commands_v2::v2_get_award_albums,
|
||||||
|
commands_v2::v2_get_award_explore,
|
||||||
commands_v2::v2_pause_playback,
|
commands_v2::v2_pause_playback,
|
||||||
commands_v2::v2_resume_playback,
|
commands_v2::v2_resume_playback,
|
||||||
commands_v2::v2_stop_playback,
|
commands_v2::v2_stop_playback,
|
||||||
|
|||||||
@@ -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
@@ -155,6 +155,7 @@
|
|||||||
import { loadArtistFavorites } from '$lib/stores/artistFavoritesStore';
|
import { loadArtistFavorites } from '$lib/stores/artistFavoritesStore';
|
||||||
import { loadLabelFavorites } from '$lib/stores/labelFavoritesStore';
|
import { loadLabelFavorites } from '$lib/stores/labelFavoritesStore';
|
||||||
import { loadAwardFavorites } from '$lib/stores/awardFavoritesStore';
|
import { loadAwardFavorites } from '$lib/stores/awardFavoritesStore';
|
||||||
|
import { resolveAwardIdByName } from '$lib/stores/awardCatalogStore';
|
||||||
import { getDefaultFavoritesTab } from '$lib/utils/favorites';
|
import { getDefaultFavoritesTab } from '$lib/utils/favorites';
|
||||||
import { platform } from '$lib/utils/platform';
|
import { platform } from '$lib/utils/platform';
|
||||||
import type { FavoritesPreferences, ResolvedMusician } from '$lib/types';
|
import type { FavoritesPreferences, ResolvedMusician } from '$lib/types';
|
||||||
@@ -1528,16 +1529,23 @@
|
|||||||
navigateTo('label-releases', labelId);
|
navigateTo('label-releases', labelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAwardClick(awardId: string, awardName: string) {
|
async function handleAwardClick(awardId: string, awardName: string) {
|
||||||
if (!awardId) {
|
// Happy path: /album/get included the id — navigate immediately.
|
||||||
// Some /album/get responses omit award.id. We still keep the
|
if (awardId) {
|
||||||
// click affordance so it looks interactive, but there is no
|
selectedAward = { id: awardId, name: awardName };
|
||||||
// target page to open — surface that to the user.
|
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');
|
showToast($t('toast.awardUnavailable'), 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selectedAward = { id: awardId, name: awardName };
|
selectedAward = { id: resolved, name: awardName };
|
||||||
navigateTo('award', awardId);
|
navigateTo('award', resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user