fix(slint): label albums missing artist/quality/date + favorite + load-more

/label/getAlbums returns the V2 nested album shape (DiscographyAlbumDto:
artists[], audio_info, dates, track_count) — confirmed against the Android
decompiled sources — but it was deserialized into the flat Album model,
which dropped artist, quality and release date (Unknown Artist in
group-by-artist, no quality badge, no date, empty list-row columns).

qbz-models Album: capture the nested V2 fields (audio_info, dates,
track_count, release_type) as optional serde(default) — backward
compatible with the flat-shape endpoints.
label.rs map_album: read nested-first (quality from audio_info, date from
dates, count from track_count, artist from artists[] main-artist, explicit
release_type) with flat fallback. Fixes the releases grid + list columns,
the landing Releases carousel, and Critics' Picks.
Wire ("album","favorite") -> add_favorite + toast (works on every album
card via the shared component).
LabelReleases load-more now driven by has_more (the endpoint caps each
page well below the catalog, so total was an unreliable gate).

cc
This commit is contained in:
vicrodh committed 2026-05-26 20:10:09 -06:00
1 parent 56c924d8ea
commit 12048bcee0
6 files changed
+171 -29

No files matched your search

+17
View File
@@ -280,6 +280,23 @@ pub struct Album {
pub hires_streamable: bool,
pub maximum_sampling_rate: Option<f64>,
pub maximum_bit_depth: Option<u32>,
/// V2 nested quality block. The modern album shape returned by
/// `/label/getAlbums` (DiscographyAlbumDto) and `/discover`-style items
/// nests quality here; preferred over the flat `maximum_*` fields.
#[serde(default)]
pub audio_info: Option<DiscoverAudioInfo>,
/// V2 nested release dates (`{original, download, stream}`); preferred
/// over the flat `release_date_original` when present.
#[serde(default)]
pub dates: Option<DiscoverAlbumDates>,
/// The V2 wire spells the album track count `track_count` (no trailing
/// `s`); the flat shape uses `tracks_count`.
#[serde(default)]
pub track_count: Option<u32>,
/// Explicit release type when provided ("album" | "ep" | "single" |
/// "live" | "compilation" | ...).
#[serde(default)]
pub release_type: Option<String>,
#[serde(default)]
pub tracks: Option<TracksContainer>,
/// Universal Product Code for the album
+121 -25
View File
@@ -32,6 +32,7 @@ pub struct LabelData {
pub image_url: String,
pub albums: Vec<AlbumCard>,
pub total: usize,
pub has_more: bool,
}
#[derive(Clone)]
@@ -79,7 +80,14 @@ where
page.name
};
let image_url = extract_label_image(page.image.as_ref());
let total = albums_page.total.unwrap_or(albums_page.items.len() as u32) as usize;
let item_count = albums_page.items.len();
let total = albums_page
.total
.map(|t| t as usize)
.unwrap_or(item_count);
// /label/getAlbums caps each page below the full catalog; trust the
// `has_more` flag, falling back to a total comparison when it's absent.
let has_more = albums_page.has_more.unwrap_or(total > item_count);
let albums = albums_page.items.into_iter().map(map_album).collect();
Ok(LabelData {
@@ -88,15 +96,17 @@ where
image_url,
albums,
total,
has_more,
})
}
/// Fetch one more album page for the load-more affordance.
/// Fetch one more album page for the load-more affordance. Returns the
/// new cards, the (best-known) total, and whether more pages remain.
pub async fn load_more_albums<A>(
runtime: &Arc<AppRuntime<A>>,
label_id: u64,
offset: u32,
) -> Result<(Vec<AlbumCard>, usize), String>
) -> Result<(Vec<AlbumCard>, usize, bool), String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
@@ -105,47 +115,81 @@ where
.get_label_albums(label_id, PAGE_SIZE, offset, None, None, None, None, None)
.await
.map_err(|e| e.to_string())?;
let total = page.total.unwrap_or(0) as usize;
let item_count = page.items.len();
let loaded = offset as usize + item_count;
let total = page.total.map(|t| t as usize).unwrap_or(loaded);
// More pages remain when the API says so, or when this page came back
// full (a short page means the catalog is exhausted).
let has_more = page
.has_more
.unwrap_or(item_count >= PAGE_SIZE as usize || total > loaded);
let albums = page.items.into_iter().map(map_album).collect();
Ok((albums, total))
Ok((albums, total, has_more))
}
fn map_album(album: Album) -> AlbumCard {
let year = album
.release_date_original
// /label/getAlbums (and the page release containers) return the V2
// nested album shape — quality under `audio_info`, dates under `dates`,
// count as `track_count`, artist in the `artists[]` array — so prefer
// those, falling back to the flat fields for other endpoints.
let bit_depth = album
.audio_info
.as_ref()
.and_then(|a| a.maximum_bit_depth)
.or(album.maximum_bit_depth);
let sample_rate = album
.audio_info
.as_ref()
.and_then(|a| a.maximum_sampling_rate)
.or(album.maximum_sampling_rate);
let quality_tier = tier_hires(bit_depth, album.hires || album.hires_streamable).to_string();
let quality_label = match (bit_depth, sample_rate) {
(Some(bd), Some(sr)) => format!("{}-bit / {} kHz", bd, sr),
_ => String::new(),
};
let quality_detail = quality_label.clone();
// Release date — nested `dates` first (original > download > stream),
// else the flat `release_date_original`.
let date = album
.dates
.as_ref()
.and_then(|d| {
d.original
.clone()
.or_else(|| d.download.clone())
.or_else(|| d.stream.clone())
})
.or_else(|| album.release_date_original.clone());
let year = crate::dates::release_label(date.as_deref());
let plain_year = date
.as_deref()
.and_then(|s| s.get(..4).map(|y| y.to_string()))
.unwrap_or_default();
let quality_tier = tier(album.maximum_bit_depth).to_string();
let quality_label = match (album.maximum_bit_depth, album.maximum_sampling_rate) {
(Some(bd), Some(sr)) => format!("{}-bit / {} kHz", bd, sr),
_ => String::new(),
};
let genre = album.genre.map(|g| g.name).unwrap_or_default();
let quality_detail = match (album.maximum_bit_depth, album.maximum_sampling_rate) {
(Some(bd), Some(sr)) => format!("{}-bit / {} kHz", bd, sr),
_ => String::new(),
};
let track_count = album
.tracks_count
let tc = album.track_count.or(album.tracks_count);
let track_count = tc
.filter(|n| *n > 0)
.map(|n| n.to_string())
.unwrap_or_default();
let release_type = classify_release_type(album.tracks_count).to_string();
let release_type = release_type_label(album.release_type.as_deref(), tc);
// Borrow `album` (artist) before any owned field is moved out below.
let (artist, artist_id) = album_artist(&album);
let genre = album.genre.map(|g| g.name).unwrap_or_default();
AlbumCard {
id: album.id,
title: album.title,
artist: album.artist.name,
artist_id: album.artist.id.to_string(),
artist,
artist_id,
genre,
year: year.clone(),
year,
quality_tier,
quality_label,
artwork_url: album.image.best().cloned().unwrap_or_default(),
release_type,
quality_detail,
track_count,
plain_year: year,
plain_year,
}
}
@@ -157,6 +201,55 @@ fn tier(bit_depth: Option<u32>) -> &'static str {
}
}
/// Quality tier from a resolved bit depth, with a `hires` boolean fallback
/// for payloads that omit the bit depth but still flag the release hi-res.
fn tier_hires(bit_depth: Option<u32>, hires: bool) -> &'static str {
match bit_depth {
Some(b) if b > 16 => "hires",
Some(_) => "cd",
None if hires => "hires",
None => "",
}
}
/// Display label for the TYPE column — the explicit `release_type` when the
/// payload provides a known one, else a track-count heuristic.
fn release_type_label(release_type: Option<&str>, track_count: Option<u32>) -> String {
match release_type {
Some("album") | Some("download") => "Album".to_string(),
Some("ep") | Some("epSingle") => "EP".to_string(),
Some("single") => "Single".to_string(),
Some("live") => "Live".to_string(),
Some("compilation") => "Compilation".to_string(),
_ => classify_release_type(track_count).to_string(),
}
}
/// Album artist name + id. Many /label/getAlbums items leave the `artist`
/// object empty and only populate the `artists` credit array, which left
/// group-by-artist showing "Unknown Artist". Fall back to the main-artist
/// credit (else the first) — mirrors artist.rs map_release.
fn album_artist(album: &Album) -> (String, String) {
if !album.artist.name.is_empty() {
return (album.artist.name.clone(), album.artist.id.to_string());
}
if let Some(list) = album.artists.as_ref() {
let pick = list
.iter()
.find(|a| {
a.roles
.as_ref()
.map(|r| r.iter().any(|role| role == "main-artist"))
.unwrap_or(false)
})
.or_else(|| list.first());
if let Some(a) = pick {
return (a.name.clone(), a.id.to_string());
}
}
(String::new(), String::new())
}
/// Classify the list-row TYPE column from the album's track count. The
/// /label/getAlbums payload carries no explicit release_type, so this
/// mirrors home.rs's Discover heuristic (<=3 = Single, <=6 = EP, else Album).
@@ -219,6 +312,7 @@ pub fn apply_label(window: &AppWindow, data: LabelData) {
state.set_image_url(data.image_url.into());
state.set_albums(ModelRc::new(VecModel::from(items)));
state.set_total(data.total as i32);
state.set_has_more(data.has_more);
state.set_loading(false);
derive_releases(window);
}
@@ -316,7 +410,7 @@ fn sort_album_items(items: &mut [AlbumCardItem], sort: &str) {
}
}
pub fn append_albums(window: &AppWindow, albums: Vec<AlbumCard>, total: usize) {
pub fn append_albums(window: &AppWindow, albums: Vec<AlbumCard>, total: usize, has_more: bool) {
let state = window.global::<LabelState>();
let model = state.get_albums();
let mut combined: Vec<AlbumCardItem> = (0..model.row_count())
@@ -325,6 +419,7 @@ pub fn append_albums(window: &AppWindow, albums: Vec<AlbumCard>, total: usize) {
combined.extend(albums.into_iter().map(to_item));
state.set_albums(ModelRc::new(VecModel::from(combined)));
state.set_total(total as i32);
state.set_has_more(has_more);
state.set_load_more_loading(false);
derive_releases(window);
}
@@ -350,6 +445,7 @@ pub fn reset_label(window: &AppWindow) {
state.set_visible(ModelRc::new(VecModel::from(Vec::<AlbumCardItem>::new())));
state.set_grouped(ModelRc::new(VecModel::from(Vec::<DiscoverSection>::new())));
state.set_total(0);
state.set_has_more(false);
state.set_loading(true);
state.set_load_more_loading(false);
// Reset the toolbar to defaults for the fresh label.
+21 -2
View File
@@ -2093,6 +2093,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
handle.clone(),
id.clone(),
),
("album", "favorite") => {
// Add the album to the user's favorites. The album cards
// (grid hover heart + the "…" menu) all bubble this; the
// shared component means one handler covers the app.
let runtime = runtime.clone();
let weak = weak.clone();
let album_id = id.clone();
handle.spawn(async move {
match runtime.core().add_favorite("album", &album_id).await {
Ok(()) => {
crate::toast::success_weak(&weak, "Added to favorites");
}
Err(e) => {
log::error!("[qbz-slint] favorite album failed: {e}");
crate::toast::error_weak(&weak, "Couldn't add to favorites");
}
}
});
}
("track", "play-next") => {
if let Ok(track_id) = id.parse::<u64>() {
playback::play_track_next(
@@ -3048,7 +3067,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let image_cache = image_cache.clone();
handle.spawn(async move {
match label::load_more_albums(&runtime, label_id, offset).await {
Ok((data, total)) => {
Ok((data, total, has_more)) => {
let jobs: Vec<artwork::ArtworkJob> = data
.iter()
.enumerate()
@@ -3061,7 +3080,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
})
.collect();
let _ = weak.upgrade_in_event_loop(move |w| {
label::append_albums(&w, data, total);
label::append_albums(&w, data, total, has_more);
});
artwork::spawn_loads(jobs, weak, image_cache);
}
+5
View File
@@ -103,3 +103,8 @@ pub fn warning(window: &AppWindow, message: impl Into<String>) {
pub fn error_weak(weak: &Weak<AppWindow>, message: impl Into<String>) {
show_weak(weak, message, ToastKind::Error);
}
/// Success toast from any thread.
pub fn success_weak(weak: &Weak<AppWindow>, message: impl Into<String>) {
show_weak(weak, message, ToastKind::Success);
}
@@ -382,8 +382,9 @@ export component LabelReleasesView inherits Rectangle {
}
// --- Load more -----------------------------------------------
if LabelState.albums.length > 0
&& LabelState.albums.length < LabelState.total: HorizontalLayout {
// Driven by `has-more` — /label/getAlbums caps each page well
// below the catalog, so `total` is an unreliable gate.
if LabelState.albums.length > 0 && LabelState.has-more: HorizontalLayout {
alignment: center;
Rectangle {
width: load-more-text.preferred-width + 24px;
+4
View File
@@ -1158,6 +1158,10 @@ export global LabelState {
in property <[AlbumCardItem]> albums: [];
in property <[AlbumCardItem]> visible: [];
in property <int> total: 0;
// Whether the catalog has more pages to fetch (drives Load more). The
// /label/getAlbums endpoint caps each page well below the full catalog,
// so this is the reliable signal — `total` is often unset/underset.
in property <bool> has-more: false;
in property <bool> loading: false;
in property <bool> load-more-loading: false;
// "grid" | "list" — the view toggle (mirrors DiscoverBrowseView).