Files
qbz/crates/qbz-qt/src/home_qt.rs
T
vicrodh b10ff98620 feat(qbz-qt): phase 9 — Queue homologation + Lyrics side panel
Queue panel 1:1: History tab (click replays per queue.rs semantics),
#442 'Next in queue'/'Next up' section markers from manual_next_count,
40-row pagination + live search + page clamp, row actions (play,
remove, remove-all-after, heart), press-drag reorder via move_track,
clear queue, plural-aware footer count, exact empty states. New Lyrics
panel: qbz-lyrics service + per-user cache wired, Qobuz-first chain
with LRCLIB fallback, synced line view with line-level highlight +
centered auto-follow + click-to-seek, exact status bodies. Both panels
stack Feishin-style in the right column; NPB lyrics button wired.

km
2026-07-27 18:22:22 -06:00

617 lines
21 KiB
Rust

//! Discover > Home data layer — a Slint-free port of the PURE mapping
//! logic of `crates/qbz/src/home.rs` (+ the personalized rails of
//! `crates/qbz/src/foryou.rs` that need only a live session).
//!
//! Produces plain data rows; serialization to the bridge happens as one
//! JSON document (`homeSectionsJson`) — see the POC-NOTE in
//! `publish_sections` for why not QVariantList-of-QVariantMap.
//!
//! POC-NOTEs (skipped vs the Slint controller):
//! - Genre filter: OUT OF SCOPE — `get_discover_index(None)` (the
//! no-filter default).
//! - Artist/album blacklist filtering (T8): the blacklist store is not
//! opened in this POC (phase-1 skip), so no rows are dropped.
//! - Recently-played rails (local play-history store): OUT OF SCOPE — the
//! view renders the Slint empty-data placeholders instead.
//! - Reco-scored taste ordering of favorite albums (reco store skipped):
//! favorites render in plain favorite order.
//! - Pinned / Most Played Albums / Qobuz Mixes rows: stores/static tiles
//! out of scope.
//! - Discover prefs (per-tab section visibility/reorder): OUT OF SCOPE —
//! a fixed superset order renders (see `SECTION_ORDER`).
//! - Editor's Picks / For You / Recommendations tabs: placeholder pages
//! (the editor section set is not built).
use std::sync::Arc;
use qbz_app::shell::AppRuntime;
use qbz_core::FrontendAdapter;
use qbz_models::{
Album, AlbumAward, Artist, DiscoverAlbum, DiscoverAudioInfo, DiscoverContainer,
DiscoverPlaylist,
};
use serde::Serialize;
/// One card row in a section rail (superset of home.rs's CardData /
/// SlimData / PlaylistCardData / foryou ArtistSlim — unused fields stay
/// empty per section kind).
#[derive(Clone, Default, Serialize)]
pub struct HomeCard {
pub id: String,
pub title: String,
pub artist: String,
#[serde(rename = "artistId")]
pub artist_id: String,
pub genre: String,
pub year: String,
#[serde(rename = "qualityTier")]
pub quality_tier: String,
#[serde(rename = "qualityLabel")]
pub quality_label: String,
#[serde(rename = "qualityDetail")]
pub quality_detail: String,
pub ribbon: String,
#[serde(rename = "ribbonKind")]
pub ribbon_kind: String,
#[serde(rename = "isPinned", default)]
pub is_pinned: bool,
/// Slim rows ("Popular albums"): the 1-based rank ("" = none).
pub rank: String,
/// Playlist rows: the UPPERCASE first-tag category subtag.
pub category: String,
#[serde(rename = "artUrl")]
pub art_url: String,
/// `file://<cached path>` when already on disk ("" = needs download).
#[serde(rename = "artPath")]
pub art_path: String,
}
#[derive(Clone, Serialize)]
pub struct HomeSection {
pub id: String,
pub title: String,
/// "album" | "slim" | "playlist" | "artists" | "recentPlaceholder".
pub kind: String,
/// Placeholder hint for recentPlaceholder sections.
pub hint: String,
pub items: Vec<HomeCard>,
}
/// Push one album-carousel section from its discover container (drops
/// empty/missing containers, like home.rs `push_section`).
fn push_albums(
out: &mut Vec<HomeSection>,
id: &str,
title: String,
container: Option<DiscoverContainer<DiscoverAlbum>>,
) {
let Some(container) = container else {
return;
};
if container.data.items.is_empty() {
return;
}
out.push(HomeSection {
id: id.to_string(),
title,
kind: "album".to_string(),
hint: String::new(),
items: container.data.items.into_iter().map(map_album).collect(),
});
}
/// The fixed render order (prefs out of scope — see module docs). Mirrors
/// the default-enabled Home order for the shared sections, then the
/// optional rails whose data is fetched anyway.
fn build_sections(
containers: qbz_models::DiscoverContainers,
favorite_albums: Vec<HomeCard>,
release_watch: Vec<HomeCard>,
top_artists: Vec<HomeCard>,
) -> Vec<HomeSection> {
let mut out: Vec<HomeSection> = Vec::new();
push_albums(&mut out, "newReleases", qbz_i18n::t("New Releases"), containers.new_releases);
push_albums(&mut out, "pressAwards", qbz_i18n::t("Press Accolades"), containers.press_awards);
// Qobuz Playlists row (single-cover cards, first-tag category subtag).
let playlists: Vec<HomeCard> = containers
.playlists
.map(|c| c.data.items)
.unwrap_or_default()
.into_iter()
.take(40)
.map(map_playlist)
.collect();
if !playlists.is_empty() {
out.push(HomeSection {
id: "qobuzPlaylists".to_string(),
title: qbz_i18n::t("Qobuz Playlists"),
kind: "playlist".to_string(),
hint: String::new(),
items: playlists,
});
}
// Recently-played rails are OUT OF SCOPE (local store) — the Slint
// empty-data placeholders render instead (always present on Home).
out.push(HomeSection {
id: "recentlyPlayedAlbums".to_string(),
title: qbz_i18n::t("Recently Played Albums"),
kind: "recentPlaceholder".to_string(),
hint: qbz_i18n::t("Albums you play will appear here."),
items: Vec::new(),
});
out.push(HomeSection {
id: "continueListening".to_string(),
title: qbz_i18n::t("Recently Played Tracks"),
kind: "recentPlaceholder".to_string(),
hint: qbz_i18n::t("Tracks you play will appear here."),
items: Vec::new(),
});
push_albums(
&mut out,
"idealDiscography",
qbz_i18n::t("Ideal Discography"),
containers.ideal_discography,
);
// Most Streamed on Home renders as the "Popular albums" slim grid,
// capped at 24 (two carousel pages of 12), 1-based ranked.
let popular: Vec<HomeCard> = containers
.most_streamed
.map(|container| container.data.items)
.unwrap_or_default()
.into_iter()
.take(24)
.enumerate()
.map(|(index, album)| map_slim(index, album))
.collect();
if !popular.is_empty() {
out.push(HomeSection {
id: "mostStreamed".to_string(),
title: qbz_i18n::t("Popular albums"),
kind: "slim".to_string(),
hint: String::new(),
items: popular,
});
}
push_albums(
&mut out,
"editorPicks",
qbz_i18n::t("Albums of the Week"),
containers.album_of_the_week,
);
push_albums(&mut out, "qobuzissimes", qbz_i18n::t("Qobuzissimes"), containers.qobuzissims);
// Personalized rails (self-hide while empty, 1:1 Slint).
if !favorite_albums.is_empty() {
out.push(HomeSection {
id: "favoriteAlbums".to_string(),
title: qbz_i18n::t("Library Albums"),
kind: "album".to_string(),
hint: String::new(),
items: favorite_albums,
});
}
if !release_watch.is_empty() {
out.push(HomeSection {
id: "releaseWatch".to_string(),
title: qbz_i18n::t("Release Watch"),
kind: "album".to_string(),
hint: String::new(),
items: release_watch,
});
}
if !top_artists.is_empty() {
out.push(HomeSection {
id: "topArtists".to_string(),
title: qbz_i18n::t("Your Top Artists"),
kind: "artists".to_string(),
hint: String::new(),
items: top_artists,
});
}
out
}
/// Fetch the discover index (UNFILTERED — genre filter out of scope) and
/// the personalized rails concurrently (mirrors home.rs's `join!`), then
/// map everything into plain rows.
pub async fn load_home<A>(runtime: &Arc<AppRuntime<A>>) -> Result<Vec<HomeSection>, String>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let (response, favorite_albums, release_watch, top_artists) = tokio::join!(
runtime.core().get_discover_index(None),
favorite_album_cards(runtime),
fetch_release_watch(runtime),
top_artist_cards(runtime),
);
let response = response.map_err(|e| e.to_string())?;
log::info!(
"[qbz-qt] discover index fetched; building home sections (fav={}, rw={}, artists={})",
favorite_albums.len(),
release_watch.len(),
top_artists.len(),
);
Ok(build_sections(
response.containers,
favorite_albums,
release_watch,
top_artists,
))
}
// ---------------------------------------------------------------------------
// Pure mapping — ported from home.rs (Discover* inputs).
// ---------------------------------------------------------------------------
pub(crate) fn map_album(album: DiscoverAlbum) -> HomeCard {
let artist = album
.artists
.first()
.map(|a| a.name.clone())
.unwrap_or_default();
let artist_id = album
.artists
.first()
.map(|a| a.id.to_string())
.unwrap_or_default();
let genre = album.genre.map(|g| g.name).unwrap_or_default();
let year = qbz_text_utils::dates::release_label(
album
.dates
.as_ref()
.and_then(|d| d.original.as_ref().or(d.download.as_ref()).or(d.stream.as_ref()))
.map(|s| s.as_str()),
);
let (ribbon, ribbon_kind) = pick_ribbon(album.awards.as_deref());
let artwork_url = album
.image
.large
.or(album.image.thumbnail)
.or(album.image.small)
.unwrap_or_default();
let is_pinned = crate::sidebar_qt::is_pinned("album", &album.id);
HomeCard {
is_pinned,
id: album.id,
title: album.title,
artist,
artist_id,
genre,
year,
quality_tier: quality_tier(album.audio_info.as_ref()).to_string(),
quality_label: quality_label(album.audio_info.as_ref()),
quality_detail: quality_detail(album.audio_info.as_ref()),
ribbon,
ribbon_kind,
art_url: artwork_url,
..HomeCard::default()
}
}
/// Map a Discover playlist into a single-cover card (1:1 home.rs
/// `map_playlist`): landscape `rectangle` preferred, first square cover
/// fallback; first tag -> UPPERCASE category subtag.
fn map_playlist(p: DiscoverPlaylist) -> HomeCard {
let art_url = p
.image
.rectangle
.or_else(|| p.image.covers.and_then(|c| c.into_iter().next()))
.unwrap_or_default();
let category = p
.tags
.as_ref()
.and_then(|t| t.first())
.map(|t| t.name.to_uppercase())
.unwrap_or_default();
HomeCard {
id: p.id.to_string(),
title: p.name,
category,
art_url,
..HomeCard::default()
}
}
/// A compact ranked slim item (home.rs `map_slim`).
fn map_slim(index: usize, album: DiscoverAlbum) -> HomeCard {
let subtitle = album
.artists
.first()
.map(|a| a.name.clone())
.unwrap_or_default();
let art_url = album
.image
.thumbnail
.or(album.image.small)
.or(album.image.large)
.unwrap_or_default();
HomeCard {
id: album.id,
title: album.title,
artist: subtitle,
rank: (index + 1).to_string(),
art_url,
..HomeCard::default()
}
}
/// Pick the single award ribbon, mirroring `pickAlbumRibbon`: award id 151
/// = Album of the Week, 88 = Qobuzissime, otherwise the last award becomes
/// a generic "press" ribbon.
pub(crate) fn pick_ribbon(awards: Option<&[AlbumAward]>) -> (String, String) {
let Some(awards) = awards else {
return (String::new(), String::new());
};
if awards.is_empty() {
return (String::new(), String::new());
}
if let Some(a) = awards.iter().find(|a| a.id.as_deref() == Some("151")) {
return (a.name.clone(), "albumOfTheWeek".to_string());
}
if let Some(a) = awards.iter().find(|a| a.id.as_deref() == Some("88")) {
return (a.name.clone(), "qobuzissime".to_string());
}
let last = awards.last().expect("non-empty checked above");
(last.name.clone(), "press".to_string())
}
/// Quality tier for the icon-only badge: 24-bit and up is Hi-Res, anything
/// else with audio info is CD-quality, no audio info hides the badge.
pub(crate) fn quality_tier(audio: Option<&DiscoverAudioInfo>) -> &'static str {
let Some(audio) = audio else {
return "";
};
match audio.maximum_bit_depth {
Some(depth) if depth >= 24 => "hires",
_ => "cd",
}
}
/// Exact-quality label for the badge hover tooltip
/// (`{tier}: {depth}-bit / {rate} kHz`).
pub(crate) fn quality_label(audio: Option<&DiscoverAudioInfo>) -> String {
let Some(audio) = audio else {
return String::new();
};
let hi_res = matches!(audio.maximum_bit_depth, Some(depth) if depth >= 24);
let tier = if hi_res { "Hi-Res" } else { "CD" };
let depth = audio
.maximum_bit_depth
.unwrap_or(if hi_res { 24 } else { 16 });
let rate = audio
.maximum_sampling_rate
.unwrap_or(if hi_res { 96.0 } else { 44.1 });
format!("{tier}: {depth}-bit / {} kHz", format_rate(rate))
}
/// Bare exact-quality detail ("24-bit / 96 kHz", no tier prefix).
pub(crate) fn quality_detail(audio: Option<&DiscoverAudioInfo>) -> String {
let Some(audio) = audio else {
return String::new();
};
let hi_res = matches!(audio.maximum_bit_depth, Some(depth) if depth >= 24);
let depth = audio
.maximum_bit_depth
.unwrap_or(if hi_res { 24 } else { 16 });
let rate = audio
.maximum_sampling_rate
.unwrap_or(if hi_res { 96.0 } else { 44.1 });
format!("{depth}-bit / {} kHz", format_rate(rate))
}
/// Format a kHz sample rate without a trailing `.0` (96.0 -> "96").
pub(crate) fn format_rate(rate: f64) -> String {
if (rate.fract()).abs() < f64::EPSILON {
format!("{}", rate as i64)
} else {
format!("{rate}")
}
}
/// Tier from a bare bit depth (album_map.rs `tier`): >16 is Hi-Res, a
/// known depth <= 16 is CD, unknown hides the badge.
pub(crate) fn quality_tier_from_depth(bit_depth: Option<u32>) -> &'static str {
match bit_depth {
Some(b) if b > 16 => "hires",
Some(_) => "cd",
None => "",
}
}
/// Bare exact-quality detail from parts, Hz- or kHz-tolerant (quality.rs
/// `detail`): "24-bit / 96 kHz".
pub(crate) fn quality_detail_from_parts(bit_depth: Option<u32>, sample_rate: Option<f64>) -> String {
let hi_res = matches!(bit_depth, Some(depth) if depth >= 24);
let depth = bit_depth.unwrap_or(if hi_res { 24 } else { 16 });
let rate = sample_rate.unwrap_or(if hi_res { 96.0 } else { 44.1 });
let rate = if rate >= 1000.0 { rate / 1000.0 } else { rate };
format!("{depth}-bit / {} kHz", format_rate(rate))
}
// ---------------------------------------------------------------------------
// Personalized rails — ported from foryou.rs (live-session only; the reco /
// blacklist stores are skipped, see module docs).
// ---------------------------------------------------------------------------
/// Flat-Album card mapping (foryou.rs `map_album`).
fn map_flat_album(album: Album) -> HomeCard {
let year = album
.release_date_original
.as_deref()
.and_then(|s| s.get(..4).map(|y| y.to_string()))
.unwrap_or_default();
let quality_tier = match album.maximum_bit_depth {
Some(d) if d >= 24 => "hires",
Some(_) => "cd",
None => "",
}
.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(),
};
HomeCard {
id: album.id,
title: album.title,
artist: album.artist.name,
artist_id: album.artist.id.to_string(),
year,
quality_tier,
quality_label,
art_url: album.image.best().cloned().unwrap_or_default(),
..HomeCard::default()
}
}
/// "Library Albums" — favorite albums, capped at 18, in favorite order
/// (reco taste-ordering skipped — cold reco store = no reorder upstream).
async fn favorite_album_cards<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<HomeCard>
where
A: FrontendAdapter + Send + Sync + 'static,
{
match runtime.core().get_favorites("albums", 100, 0).await {
Ok(value) => qbz_models::lenient::parse_items_array::<Album>(&value, "albums", "home fav album")
.into_iter()
.take(18)
.map(map_flat_album)
.collect(),
Err(e) => {
log::warn!("[qbz-qt] favorite albums fetch failed: {e}");
Vec::new()
}
}
}
/// "Release Watch" — `/release/watch` artists, capped 18 (blacklist filter
/// skipped — the store is not open in this POC).
async fn fetch_release_watch<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<HomeCard>
where
A: FrontendAdapter + Send + Sync + 'static,
{
match runtime.core().get_release_watch("artists", 18, 0).await {
Ok(page) => page.items.into_iter().map(map_flat_album).collect(),
Err(e) => {
log::warn!("[qbz-qt] release watch fetch failed: {e}");
Vec::new()
}
}
}
/// "Your Top Artists" — favorite artists, capped 18.
async fn top_artist_cards<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<HomeCard>
where
A: FrontendAdapter + Send + Sync + 'static,
{
match runtime.core().get_favorites("artists", 50, 0).await {
Ok(value) => qbz_models::lenient::parse_items_array::<Artist>(&value, "artists", "home artist")
.into_iter()
.take(18)
.map(|a| HomeCard {
id: a.id.to_string(),
title: a.name,
art_url: a
.image
.and_then(|img| img.best().cloned())
.unwrap_or_default(),
..HomeCard::default()
})
.collect(),
Err(e) => {
log::warn!("[qbz-qt] favorite artists fetch failed: {e}");
Vec::new()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn audio(depth: Option<u32>, rate: Option<f64>) -> DiscoverAudioInfo {
DiscoverAudioInfo {
maximum_sampling_rate: rate,
maximum_bit_depth: depth,
maximum_channel_count: None,
}
}
fn award(id: Option<&str>, name: &str) -> AlbumAward {
AlbumAward {
id: id.map(|s| s.to_string()),
name: name.to_string(),
awarded_at: None,
}
}
#[test]
fn quality_tier_mapping() {
assert_eq!(quality_tier(None), "");
assert_eq!(quality_tier(Some(&audio(Some(24), Some(96.0)))), "hires");
assert_eq!(quality_tier(Some(&audio(Some(16), Some(44.1)))), "cd");
// Bit depth present but < 24 is CD even without a rate.
assert_eq!(quality_tier(Some(&audio(Some(16), None))), "cd");
// No depth at all still maps to CD (audio info exists).
assert_eq!(quality_tier(Some(&audio(None, None))), "cd");
}
#[test]
fn quality_label_and_detail() {
assert_eq!(quality_label(None), "");
assert_eq!(quality_detail(None), "");
assert_eq!(
quality_label(Some(&audio(Some(24), Some(96.0)))),
"Hi-Res: 24-bit / 96 kHz"
);
assert_eq!(
quality_label(Some(&audio(Some(16), Some(44.1)))),
"CD: 16-bit / 44.1 kHz"
);
assert_eq!(
quality_detail(Some(&audio(Some(24), Some(192.0)))),
"24-bit / 192 kHz"
);
// Missing fields fall back per tier (hi-res: 24/96, cd: 16/44.1).
assert_eq!(quality_label(Some(&audio(Some(24), None))), "Hi-Res: 24-bit / 96 kHz");
assert_eq!(quality_detail(Some(&audio(None, Some(44.1)))), "16-bit / 44.1 kHz");
}
#[test]
fn format_rate_trailing_zero() {
assert_eq!(format_rate(96.0), "96");
assert_eq!(format_rate(44.1), "44.1");
}
#[test]
fn pick_ribbon_precedence() {
assert_eq!(pick_ribbon(None), (String::new(), String::new()));
assert_eq!(pick_ribbon(Some(&[])), (String::new(), String::new()));
// 151 wins over everything.
assert_eq!(
pick_ribbon(Some(&[award(Some("88"), "Qobuzissime"), award(Some("151"), "AOTW")])),
("AOTW".to_string(), "albumOfTheWeek".to_string())
);
// 88 wins over a generic press award.
assert_eq!(
pick_ribbon(Some(&[award(Some("1"), "Press X"), award(Some("88"), "Qobuzissime")])),
("Qobuzissime".to_string(), "qobuzissime".to_string())
);
// Otherwise the LAST award is the press ribbon.
assert_eq!(
pick_ribbon(Some(&[award(Some("1"), "First"), award(Some("2"), "Last")])),
("Last".to_string(), "press".to_string())
);
// Awards without ids are skipped by the id lookups.
assert_eq!(
pick_ribbon(Some(&[award(None, "NoId")])),
("NoId".to_string(), "press".to_string())
);
}
}