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:
6 files changed
+171
-29
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user