fix(discover): sub-genre filter sends raw ids to the server (Tauri parity)
/discover/index honors sub-genre ids server-side, so send the raw selection (parent or sub-genre, exactly as toggled) instead of mapping to the top-level ancestor and narrowing client-side by genre.path — the previous hybrid widened sub-genre selections back to their parent and dropped albums tagged only at top level. Removes the client-side narrowing and its now-dead helpers; keeps a diagnostic log of the outgoing genre_ids. cc
This commit is contained in:
4 files changed
+100
-86
No files matched your search
@@ -766,6 +766,10 @@ pub struct LabelGetListResponse {
|
|||||||
pub struct Genre {
|
pub struct Genre {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// Full ancestor id chain (top-level first, self last) as sent by the
|
||||||
|
/// discover endpoints. Absent on older cached payloads → None.
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: Option<Vec<u64>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Genre info with full details
|
/// Genre info with full details
|
||||||
|
|||||||
@@ -1113,7 +1113,11 @@ impl QobuzClient {
|
|||||||
let http_response = self
|
let http_response = self
|
||||||
.signed_get_auth(&url, "discoverindex", &query.iter().map(|(k, v)| (*k, v.clone())).collect::<Vec<_>>())
|
.signed_get_auth(&url, "discoverindex", &query.iter().map(|(k, v)| (*k, v.clone())).collect::<Vec<_>>())
|
||||||
.await?;
|
.await?;
|
||||||
log::info!("[API] get_discover_index status={}", http_response.status());
|
log::info!(
|
||||||
|
"[API] get_discover_index genre_ids={:?} status={}",
|
||||||
|
query,
|
||||||
|
http_response.status()
|
||||||
|
);
|
||||||
let response: Value = http_response.json().await?;
|
let response: Value = http_response.json().await?;
|
||||||
|
|
||||||
// Debug: log the response structure
|
// Debug: log the response structure
|
||||||
@@ -1164,8 +1168,9 @@ impl QobuzClient {
|
|||||||
.signed_get_auth(&url, &method_name, &query.iter().map(|(k, v)| (*k, v.clone())).collect::<Vec<_>>())
|
.signed_get_auth(&url, &method_name, &query.iter().map(|(k, v)| (*k, v.clone())).collect::<Vec<_>>())
|
||||||
.await?;
|
.await?;
|
||||||
log::info!(
|
log::info!(
|
||||||
"[API] get_discover_albums({}) status={}",
|
"[API] get_discover_albums({}) query={:?} status={}",
|
||||||
endpoint,
|
endpoint,
|
||||||
|
query,
|
||||||
http_response.status()
|
http_response.status()
|
||||||
);
|
);
|
||||||
let response: serde_json::Value = http_response.json().await?;
|
let response: serde_json::Value = http_response.json().await?;
|
||||||
|
|||||||
@@ -9,15 +9,15 @@
|
|||||||
//!
|
//!
|
||||||
//! Pagination is driven off the backend `has_more` flag (the discover
|
//! Pagination is driven off the backend `has_more` flag (the discover
|
||||||
//! endpoints carry no `total`): each page advances `offset` by the
|
//! endpoints carry no `total`): each page advances `offset` by the
|
||||||
//! returned item count and stops once `has_more` is false. Reuses the
|
//! FETCHED item count and stops once `has_more` is false. Reuses the
|
||||||
//! Discover home mappers (`crate::home::map_album` / `card_to_item`) so
|
//! Discover home mappers (`crate::home::map_album` / `card_to_item`) so the
|
||||||
//! the cards carry the same genre + localized release date as the home
|
//! cards carry the same genre + localized release date as the home carousels.
|
||||||
//! carousels.
|
|
||||||
//!
|
//!
|
||||||
//! Header tools (mirroring Tauri's DiscoverBrowseView): a client-side
|
//! Header tools (mirroring Tauri's DiscoverBrowseView): a client-side
|
||||||
//! search filter over the loaded albums (disables load-more while
|
//! search filter over the loaded albums (disables load-more while active),
|
||||||
//! active), the shared genre filter (re-fetches from offset 0 with the
|
//! the shared genre filter (re-fetches from offset 0 with the raw selected
|
||||||
//! selected genre ids), and a grid/list view toggle.
|
//! genre ids — Qobuz facets sub-genre ids server-side, no client narrowing),
|
||||||
|
//! and a grid/list view toggle.
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -32,6 +32,44 @@ use crate::{AlbumCardItem, AppWindow, ContentView, DiscoverBrowseState, NavState
|
|||||||
/// Page size — two carousel pages' worth, fetched per request.
|
/// Page size — two carousel pages' worth, fetched per request.
|
||||||
const PAGE_SIZE: u32 = 50;
|
const PAGE_SIZE: u32 = 50;
|
||||||
|
|
||||||
|
/// Fetch one page starting at `offset`, dropping blacklisted albums. Returns
|
||||||
|
/// the surviving cards, the FETCHED item count (the server offset advances by
|
||||||
|
/// the fetched — not visible — count; the blacklist drop is log-only) and
|
||||||
|
/// `has_more`. Genre filtering is server-side: the raw selection (parent or
|
||||||
|
/// sub-genre id) is in `genre_ids` and Qobuz honors sub-genre ids, so there is
|
||||||
|
/// no client-side narrowing — 1:1 with Tauri discovery-v2.
|
||||||
|
async fn fetch_pages(
|
||||||
|
runtime: &Arc<AppRuntime<SlintAdapter>>,
|
||||||
|
endpoint: &str,
|
||||||
|
genre_ids: Option<Vec<u64>>,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<(Vec<CardData>, u32, bool), String> {
|
||||||
|
let mut data = runtime
|
||||||
|
.core()
|
||||||
|
.get_discover_albums(endpoint, genre_ids, offset, PAGE_SIZE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let has_more = data.has_more;
|
||||||
|
let fetched = data.items.len() as u32;
|
||||||
|
// T8: drop blacklisted DiscoverAlbums (ANY of artists[], featured-aware via
|
||||||
|
// discover_album_blacklisted). Tauri's discover surfaces log-only — no count
|
||||||
|
// adjustment (the endpoints carry no `total`; pagination is has_more-driven).
|
||||||
|
let (bl, abl) = if crate::artist_blacklist::is_enabled() {
|
||||||
|
(
|
||||||
|
crate::artist_blacklist::ids_snapshot(),
|
||||||
|
crate::artist_blacklist::album_ids_snapshot(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Default::default()
|
||||||
|
};
|
||||||
|
if !bl.is_empty() || !abl.is_empty() {
|
||||||
|
data.items
|
||||||
|
.retain(|a| !qbz_core::core::discover_album_blacklisted(a, &bl, &abl));
|
||||||
|
}
|
||||||
|
let cards: Vec<CardData> = data.items.into_iter().map(crate::home::map_album).collect();
|
||||||
|
Ok((cards, fetched, has_more))
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the full-list page for `endpoint` and load its first page, then
|
/// Open the full-list page for `endpoint` and load its first page, then
|
||||||
/// fan out artwork. `genre_ids` is the shared genre-filter selection
|
/// fan out artwork. `genre_ids` is the shared genre-filter selection
|
||||||
/// (None = no filter). Mirrors `navigate_favorites` in main.rs.
|
/// (None = no filter). Mirrors `navigate_favorites` in main.rs.
|
||||||
@@ -68,41 +106,18 @@ pub fn navigate(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
match runtime
|
match fetch_pages(&runtime, &endpoint_for_fetch, genre_for_fetch, 0).await {
|
||||||
.core()
|
// CardData is plain/Send — map it to the (non-Send)
|
||||||
.get_discover_albums(&endpoint_for_fetch, genre_for_fetch, 0, PAGE_SIZE)
|
// AlbumCardItem inside the event-loop closure below. The offset
|
||||||
.await
|
// advances by the FETCHED count (blacklist drop is log-only).
|
||||||
{
|
Ok((cards, fetched, has_more)) => {
|
||||||
Ok(mut data) => {
|
|
||||||
let has_more = data.has_more;
|
|
||||||
// T8: drop blacklisted DiscoverAlbums (ANY of artists[],
|
|
||||||
// featured-aware via discover_album_blacklisted). Tauri's
|
|
||||||
// discover surfaces log-only — no count adjustment (the
|
|
||||||
// endpoints carry no `total`; pagination is has_more-driven).
|
|
||||||
let (bl, abl) = if crate::artist_blacklist::is_enabled() {
|
|
||||||
(
|
|
||||||
crate::artist_blacklist::ids_snapshot(),
|
|
||||||
crate::artist_blacklist::album_ids_snapshot(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Default::default()
|
|
||||||
};
|
|
||||||
if !bl.is_empty() || !abl.is_empty() {
|
|
||||||
data.items
|
|
||||||
.retain(|a| !qbz_core::core::discover_album_blacklisted(a, &bl, &abl));
|
|
||||||
}
|
|
||||||
// CardData is plain/Send — map it to the (non-Send)
|
|
||||||
// AlbumCardItem inside the event-loop closure below.
|
|
||||||
let cards: Vec<CardData> =
|
|
||||||
data.items.into_iter().map(crate::home::map_album).collect();
|
|
||||||
let next_offset = cards.len() as i32;
|
|
||||||
let jobs = artwork_jobs(&cards, 0);
|
let jobs = artwork_jobs(&cards, 0);
|
||||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
let items: Vec<AlbumCardItem> =
|
let items: Vec<AlbumCardItem> =
|
||||||
cards.into_iter().map(crate::home::card_to_item).collect();
|
cards.into_iter().map(crate::home::card_to_item).collect();
|
||||||
let state = w.global::<DiscoverBrowseState>();
|
let state = w.global::<DiscoverBrowseState>();
|
||||||
state.set_albums(ModelRc::new(VecModel::from(items)));
|
state.set_albums(ModelRc::new(VecModel::from(items)));
|
||||||
state.set_next_offset(next_offset);
|
state.set_next_offset(fetched as i32);
|
||||||
state.set_has_more(has_more);
|
state.set_has_more(has_more);
|
||||||
state.set_loading(false);
|
state.set_loading(false);
|
||||||
apply_filter(&w);
|
apply_filter(&w);
|
||||||
@@ -145,35 +160,18 @@ pub fn load_more(
|
|||||||
}
|
}
|
||||||
let endpoint = state.get_endpoint().to_string();
|
let endpoint = state.get_endpoint().to_string();
|
||||||
let offset = state.get_next_offset().max(0) as u32;
|
let offset = state.get_next_offset().max(0) as u32;
|
||||||
|
// New cards land after the currently-loaded albums. With narrowing
|
||||||
|
// active the server offset outruns the model length, so the artwork
|
||||||
|
// base index must come from the model, not the offset.
|
||||||
|
let base_index = state.get_albums().row_count();
|
||||||
state.set_loading_more(true);
|
state.set_loading_more(true);
|
||||||
|
|
||||||
handle.spawn(async move {
|
handle.spawn(async move {
|
||||||
match runtime
|
match fetch_pages(&runtime, &endpoint, genre_ids, offset).await {
|
||||||
.core()
|
Ok((cards, fetched, has_more)) => {
|
||||||
.get_discover_albums(&endpoint, genre_ids, offset, PAGE_SIZE)
|
let jobs = artwork_jobs(&cards, base_index);
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(mut data) => {
|
|
||||||
let has_more = data.has_more;
|
|
||||||
// T8: same featured-aware drop as the first page (log-only,
|
|
||||||
// no count adjustment).
|
|
||||||
let (bl, abl) = if crate::artist_blacklist::is_enabled() {
|
|
||||||
(
|
|
||||||
crate::artist_blacklist::ids_snapshot(),
|
|
||||||
crate::artist_blacklist::album_ids_snapshot(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Default::default()
|
|
||||||
};
|
|
||||||
if !bl.is_empty() || !abl.is_empty() {
|
|
||||||
data.items
|
|
||||||
.retain(|a| !qbz_core::core::discover_album_blacklisted(a, &bl, &abl));
|
|
||||||
}
|
|
||||||
let cards: Vec<CardData> =
|
|
||||||
data.items.into_iter().map(crate::home::map_album).collect();
|
|
||||||
let jobs = artwork_jobs(&cards, offset as usize);
|
|
||||||
let _ = weak.upgrade_in_event_loop(move |w| {
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
append_albums(&w, cards, has_more);
|
append_albums(&w, cards, fetched, has_more);
|
||||||
});
|
});
|
||||||
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
|
||||||
}
|
}
|
||||||
@@ -188,17 +186,17 @@ pub fn load_more(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Append a freshly-fetched page onto the existing grid, advancing the
|
/// Append a freshly-fetched page onto the existing grid, advancing the
|
||||||
/// offset by the new item count and updating has-more. UI thread only.
|
/// offset by the FETCHED item count (`cards` may be a narrowed subset)
|
||||||
fn append_albums(window: &AppWindow, cards: Vec<CardData>, has_more: bool) {
|
/// and updating has-more. UI thread only.
|
||||||
|
fn append_albums(window: &AppWindow, cards: Vec<CardData>, fetched: u32, has_more: bool) {
|
||||||
let state = window.global::<DiscoverBrowseState>();
|
let state = window.global::<DiscoverBrowseState>();
|
||||||
let model = state.get_albums();
|
let model = state.get_albums();
|
||||||
let mut combined: Vec<AlbumCardItem> = (0..model.row_count())
|
let mut combined: Vec<AlbumCardItem> = (0..model.row_count())
|
||||||
.filter_map(|i| model.row_data(i))
|
.filter_map(|i| model.row_data(i))
|
||||||
.collect();
|
.collect();
|
||||||
let added = cards.len() as i32;
|
|
||||||
combined.extend(cards.into_iter().map(crate::home::card_to_item));
|
combined.extend(cards.into_iter().map(crate::home::card_to_item));
|
||||||
state.set_albums(ModelRc::new(VecModel::from(combined)));
|
state.set_albums(ModelRc::new(VecModel::from(combined)));
|
||||||
state.set_next_offset(state.get_next_offset() + added);
|
state.set_next_offset(state.get_next_offset() + fetched as i32);
|
||||||
state.set_has_more(has_more);
|
state.set_has_more(has_more);
|
||||||
state.set_loading_more(false);
|
state.set_loading_more(false);
|
||||||
apply_filter(window);
|
apply_filter(window);
|
||||||
|
|||||||
@@ -147,21 +147,16 @@ pub fn selected_ids() -> Vec<u64> {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The ids to filter by for `ctx`: its selection plus every loaded
|
/// The RAW genre selection for `ctx` (no expansion, no ancestor mapping):
|
||||||
/// descendant of each selected genre (selecting a parent covers its
|
/// the exact ids the user toggled, parent or sub-genre. This is what gets
|
||||||
/// children). Server-side genre filter for Discover.
|
/// sent to /discover/* in `genre_ids` — Qobuz honors sub-genre ids
|
||||||
pub fn filter_ids(ctx: &str) -> Vec<u64> {
|
/// server-side (1:1 with Tauri discovery-v2, which sent the raw selection
|
||||||
let Ok(s) = STATE.lock() else {
|
/// straight through and did no client-side narrowing).
|
||||||
return Vec::new();
|
pub fn selected_ids_for(ctx: &str) -> Vec<u64> {
|
||||||
};
|
STATE
|
||||||
let mut out: HashSet<u64> = HashSet::new();
|
.lock()
|
||||||
if let Some(sel) = s.selected.get(ctx) {
|
.map(|s| s.selected.get(ctx).cloned().unwrap_or_default())
|
||||||
for id in sel {
|
.unwrap_or_default()
|
||||||
out.insert(*id);
|
|
||||||
collect_descendants(&s.children, *id, &mut out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.into_iter().collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selected genre NAMES (+ descendant names) for `ctx` — for the
|
/// Selected genre NAMES (+ descendant names) for `ctx` — for the
|
||||||
@@ -268,7 +263,10 @@ where
|
|||||||
let kids: Vec<GenreItem> = match runtime.core().get_genres(Some(parent_id)).await {
|
let kids: Vec<GenreItem> = match runtime.core().get_genres(Some(parent_id)).await {
|
||||||
Ok(list) => list
|
Ok(list) => list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|g| GenreItem { id: g.id, name: g.name })
|
.map(|g| GenreItem {
|
||||||
|
id: g.id,
|
||||||
|
name: g.name,
|
||||||
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("[qbz-slint] genre filter: get_genres({parent_id}) failed: {e}");
|
log::warn!("[qbz-slint] genre filter: get_genres({parent_id}) failed: {e}");
|
||||||
@@ -302,7 +300,8 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Eager-load a genre's full descendant subtree (children + grandchildren),
|
/// Eager-load a genre's full descendant subtree (children + grandchildren),
|
||||||
/// so a selection expands correctly in filter_ids / selected_names.
|
/// so a selection expands correctly in selected_names (favorites) and the
|
||||||
|
/// tree shows counts.
|
||||||
pub async fn load_descendants<A>(runtime: &AppRuntime<A>, id: u64)
|
pub async fn load_descendants<A>(runtime: &AppRuntime<A>, id: u64)
|
||||||
where
|
where
|
||||||
A: FrontendAdapter + Send + Sync + 'static,
|
A: FrontendAdapter + Send + Sync + 'static,
|
||||||
@@ -394,16 +393,24 @@ fn build_tree_rows(s: &State) -> Vec<GenreTreeRow> {
|
|||||||
let mut rows: Vec<GenreTreeRow> = Vec::new();
|
let mut rows: Vec<GenreTreeRow> = Vec::new();
|
||||||
|
|
||||||
if !query.is_empty() {
|
if !query.is_empty() {
|
||||||
|
// Search rows are a flat list that ignores expansion — never show
|
||||||
|
// an expand chevron (level 0 would otherwise force has_children on
|
||||||
|
// child matches).
|
||||||
let matches = |g: &GenreItem| g.name.to_lowercase().contains(&query);
|
let matches = |g: &GenreItem| g.name.to_lowercase().contains(&query);
|
||||||
|
let flat_row = |g: &GenreItem| {
|
||||||
|
let mut row = tree_row(g, 0, s);
|
||||||
|
row.has_children = false;
|
||||||
|
row
|
||||||
|
};
|
||||||
for p in &s.parents {
|
for p in &s.parents {
|
||||||
if matches(p) {
|
if matches(p) {
|
||||||
rows.push(tree_row(p, 0, s));
|
rows.push(flat_row(p));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for kids in s.children.values() {
|
for kids in s.children.values() {
|
||||||
for k in kids {
|
for k in kids {
|
||||||
if matches(k) {
|
if matches(k) {
|
||||||
rows.push(tree_row(k, 0, s));
|
rows.push(flat_row(k));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user