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:
vicrodh committed 2026-07-16 16:30:12 -06:00
1 parent f778874fdd
commit 6aa314b8ef
4 files changed
+100 -86

No files matched your search

+4
View File
@@ -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
+7 -2
View File
@@ -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?;
+63 -65
View File
@@ -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);
+26 -19
View File
@@ -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));
} }
} }
} }