Files
qbz/crates/qbz-qt/src/foryou_qt.rs
T
vicrodh ef665dcc5c feat(models): QueueTrack carries isrc/recording_mbid; Track models audio_info
QueueTrack gains two additive, serde-default identity keys (the ISRC and
the MusicBrainz recording id) that the listen log stamps; every
constructor in the workspace sets them (Qobuz-backed ones from
Track.isrc, local ones from the scanned tags, the rest None). They are
JOIN keys, not the row identity: numeric ids collide across sources, so
(source, native item id) stays the identity.

Track.audio_info (replaygain_track_gain / peak on DiscoverAudioInfo) is
modelled from the captured search envelopes; no reader yet and no
popularity field (0 in every sample).

cc
2026-08-28 20:30:42 -06:00

1342 lines
48 KiB
Rust

//! Discover > For You — the three rails that are NOT plain album carousels,
//! plus the Qobuz mix landing page they lead to.
//!
//! Ported from the Slint build, which spreads the same feature over four
//! files:
//! - `discover/QobuzMixesRow.slint` — the four static navigation tiles
//! (already in `HomeView.qml`; this module supplies what they OPEN).
//! - `discover/RadioCarousel.slint` + `RadioCard.slint` — album-seeded
//! radio tiles, built by `foryou.rs::build_radio`.
//! - `discover/Spotlight.slint` — one favourite artist, built by
//! `foryou.rs::load_spotlight`.
//! - `mix/MixView.slint` + `crates/qbz/src/mix.rs` — the DailyQ / WeeklyQ /
//! FavQ / TopQ detail page.
//!
//! ## Transport
//!
//! Radio + Spotlight ride their OWN bridge properties (`radioStationsJson` /
//! `spotlightJson`), NOT the three tab documents — the `pinned` precedent
//! (`home_qt::publish_pinned`). The tab documents carry an EMPTY ordering
//! slot for each (`kind: "radio"` / `"spotlight"`), which is where the
//! discover prefs place the rail; the rows arrive separately so the Spotlight
//! landing (one extra `/artist/page` round trip) never republishes — and so
//! never re-creates the delegates of — every other rail on all three tabs.
//! HomeView self-hides both slots while their document is empty, 1:1 with the
//! Slint `ForYouState.radio-stations.length > 0` / `spotlight-visible` gates.
//!
//! The mix page is ONE document (`mixJson`), the `label_qt` shape: header
//! fields + `tracks[]` in the `qml/rows/TrackRow.qml` item contract, with a
//! generation counter so a stale fetch cannot overwrite a newer page.
//!
//! ## Shared crates reused (no new client code)
//!
//! | need | shared call |
//! |---|---|
//! | DailyQ / WeeklyQ | `core().get_dynamic_suggest_full()` + `get_dynamic_suggest()` fallback |
//! | seed resolution | `core().get_track()` (the `track_to_analysed` payload) |
//! | FavQ | `core().get_favorites("tracks", 200, 0)` |
//! | TopQ | `core().get_user_playlists()` + `get_playlist()` |
//! | Spotlight | `core().get_artist_page()` (+ `artist_qt::map_release` shape) |
//! | album radio | `core().get_radio_album()` + `get_tracks_batch()` enrich |
//! | artist radio | `core().create_smart_artist_radio()` (the `qbz-radio` pool builder) |
//! | qobuz artist radio | `core().get_radio_artist()` + `get_tracks_batch()` enrich |
//! | artist top tracks| `playback_qt::play_artist()` |
//!
//! ## POC-NOTEs (deliberate deltas vs the Slint build)
//! - `mix_listened_seed_ids` prefers the reco store's scored seeds in Slint
//! (`crate::reco::home_seeds`) and falls back to recents + favourites when
//! the store is cold. This port has no `qbz-reco` dependency, so it is the
//! FALLBACK path only — the same delta `home_qt` already documents for
//! "Rediscover Your Library".
//! - The shared artist blacklist store is live, but mix-row mapping does not
//! yet consult it, so the rows carry no `isBlacklisted` grey-out.
//! - The mix page has no multi-select bar: `qml/rows/TrackRow.qml` has no
//! multi-select arm and two of the Slint bar's seven actions have no bridge
//! seam at all — the same call `LabelView.qml` already documents. The
//! Slint header's select disc renders DIMMED and inert here (the ArtistView
//! / LabelView precedent), and "Add to playlist" (`list-plus`) is likewise
//! inert, exactly as in the Slint header where it carries no `clicked`.
use std::collections::{BTreeMap, HashSet};
use std::sync::{Arc, Mutex};
use cxx_qt_lib::QString;
use qbz_app::shell::AppRuntime;
use qbz_core::{FrontendAdapter, LoggingAdapter};
use qbz_models::{Album, Artist, QueueTrack, Track, TrackToAnalyse};
use serde::Serialize;
use serde_json::Value;
use crate::album_qt::TrackRow;
use crate::home_qt::{attach_card_art, HomeCard};
// ===========================================================================
// Radio Stations rail (RadioCarousel.slint + foryou.rs build_radio)
// ===========================================================================
/// One album-seeded radio tile (`state.slint` `RadioStationItem`).
#[derive(Clone, Default, Serialize)]
pub struct RadioSeedRow {
#[serde(rename = "albumId")]
pub album_id: String,
pub title: String,
pub artist: String,
#[serde(rename = "artUrl")]
pub art_url: String,
#[serde(rename = "artPath")]
pub art_path: String,
}
static RADIO: Mutex<Vec<RadioSeedRow>> = Mutex::new(Vec::new());
/// Radio Stations — album-seeded tiles from recent + favourite albums,
/// deduped, capped at 12 (`foryou.rs::build_radio`, verbatim including the
/// Qobuz-source gate: `/radio/album` cannot resolve a local or Plex album id,
/// and an empty source is a legacy entry treated as Qobuz).
pub(crate) fn build_radio(
recent_albums: &[crate::recently_qt::RecentAlbum],
fav_albums: &[Album],
) -> Vec<RadioSeedRow> {
let mut seen: HashSet<String> = HashSet::new();
let mut out: Vec<RadioSeedRow> = Vec::new();
for a in recent_albums {
if !(a.source.is_empty() || a.source.eq_ignore_ascii_case("qobuz")) {
continue;
}
if seen.insert(a.id.clone()) {
out.push(RadioSeedRow {
album_id: a.id.clone(),
title: a.title.clone(),
artist: a.artist.clone(),
art_url: a.artwork_url.clone(),
art_path: String::new(),
});
}
}
for a in fav_albums {
if out.len() >= 12 {
break;
}
if seen.insert(a.id.clone()) {
out.push(RadioSeedRow {
album_id: a.id.clone(),
title: a.title.clone(),
artist: a.artist.name.clone(),
// Grid card (RadioCard on the For You rail): full variant
// (best()) — the down-tier was reverted after the 2026-08-15
// owner smoke (contract 04 §3).
art_url: a.image.best().cloned().unwrap_or_default(),
art_path: String::new(),
});
}
}
out.truncate(12);
out
}
/// The landed covers of ONE batch as a `{artUrl: file://path}` patch, instead
/// of a second full document publish.
///
/// Two separate decisions live here.
///
/// (a) PER URL, NOT PER DOCUMENT. The two-pass `label_qt` shape republished the
/// whole JSON when the covers arrived, which hands QML's `model:` a NEW JS
/// array; `QQuickItemView::setModel()` then resets `contentX` to 0 and tears
/// down the QQmlDelegateModel (this build's only crash signature — `main.rs`
/// documents it for the Library feed, and `LibraryView.qml` documents the cure:
/// patch in place, never re-emit the document).
///
/// (b) ONE EMIT PER BATCH, NOT ONE PER URL. `HomeView.qml`'s `forYouArt` is a
/// `var` property, so only a NEW object reference notifies its dependents: an
/// emit per url paid one copy of the (growing) map AND one invalidation sweep
/// over every `forYouArtOf(...)` binding in both rails plus the `artPending`
/// probe — quadratic in the ~38 covers a cold For You load resolves, for 38 Qt
/// thread hops. Nothing is shown any later for it: `download_missing().await`
/// settles the WHOLE batch before the first path can be read back.
///
/// The emit shape is the port's existing signal helper (`emit_library_favorite`
/// in main.rs): a cxx-qt signal is emitted by calling the declared Rust method
/// on `Pin<&mut T>`.
fn emit_foryou_art(patch: BTreeMap<String, String>) {
if patch.is_empty() {
return;
}
let json = patch_json(&patch);
crate::home_bridge::ui(move |mut b| {
b.as_mut().foryou_art_ready(QString::from(json.as_str()));
});
}
fn patch_json(patch: &BTreeMap<String, String>) -> String {
serde_json::to_string(patch).unwrap_or_else(|_| "{}".to_string())
}
/// Collect one resolved cover into the patch (blanks are not patches).
fn collect_resolved(patch: &mut BTreeMap<String, String>, url: &str, path: &str) {
if !url.is_empty() && !path.is_empty() {
patch.insert(url.to_string(), path.to_string());
}
}
/// Every For You cover the stores have ALREADY resolved, as the same
/// `{artUrl: file://path}` patch [`emit_foryou_art`] sends — or "" when there
/// is nothing to hand over yet.
///
/// WHY THIS EXISTS. `AppShell.qml` binds `viewLoader.source` to
/// `QbzShell.currentView`, so HomeView is DESTROYED on every navigation and its
/// per-url `forYouArt` map dies with it, while the rails' documents are
/// deliberately never republished for artwork (see [`emit_foryou_art`]) and
/// `publish_radio` / `publish_spotlight` run ONCE per session (`home_qt::
/// load_home` <- `reload_home` <- the boot chain in main.rs; otherwise only the
/// error-retry button and a language change). Without a re-hand on mount the
/// Radio rail and the Spotlight hero + album tiles come back BLANK for the rest
/// of the session — and `anyForYouArtPending` stays true forever, which pins
/// HomeView's 900 ms skeleton pulse Timer permanently ON, i.e. it also defeats
/// the perf fix this transport exists for.
///
/// It re-resolves the cache paths into the stores first, so a cover that landed
/// while Home was unmounted (that emit reached nobody) — or that some other
/// view downloaded — is picked up. `cached_path` is memoized, so the second and
/// later calls touch neither SQLite nor the disk.
///
/// It NEVER downloads and NEVER republishes a document, and it returns "" when
/// nothing is resolved, so the FIRST (cold) mount emits nothing at all and
/// cannot clear a map the in-flight download is about to fill.
pub(crate) fn resolved_art_patch() -> String {
let mut patch: BTreeMap<String, String> = BTreeMap::new();
if let Ok(mut store) = RADIO.lock() {
let _ = attach_radio_art(&mut store);
for row in store.iter() {
collect_resolved(&mut patch, &row.art_url, &row.art_path);
}
}
if let Ok(mut store) = SPOTLIGHT.lock() {
if let Some(doc) = store.as_mut() {
let _ = attach_spotlight_art(doc);
collect_resolved(&mut patch, &doc.art_url, &doc.art_path);
for card in doc.albums.iter() {
collect_resolved(&mut patch, &card.art_url, &card.art_path);
}
}
}
if patch.is_empty() {
return String::new();
}
log::debug!(
"[qbz-qt] for-you art re-handed to a fresh HomeView: {} covers",
patch.len()
);
patch_json(&patch)
}
/// Store + publish the radio rail ONCE, then fetch the covers it is missing
/// and hand them to the rail as a url-keyed patch (see [`emit_foryou_art`]).
pub(crate) fn publish_radio(rows: Vec<RadioSeedRow>) {
let missing = {
let Ok(mut store) = RADIO.lock() else {
return;
};
*store = rows;
attach_radio_art(&mut store)
};
push_radio();
if missing.is_empty() {
return;
}
crate::spawn(async move {
crate::artwork_qt::download_missing(missing.clone()).await;
// In place: the rows keep the art they already have and the landed
// covers reach the rail as one url-keyed patch. The store is still
// updated so a LATER full publish (a new Home load) carries the
// paths — and so `resolved_art_patch` can re-hand them to a HomeView
// that was destroyed and re-created meanwhile — but nothing
// republishes for artwork alone.
if let Ok(mut store) = RADIO.lock() {
let _ = attach_radio_art(&mut store);
}
let mut patch: BTreeMap<String, String> = BTreeMap::new();
for url in missing {
let path = crate::artwork_qt::cached_path(&url);
collect_resolved(&mut patch, &url, &path);
}
emit_foryou_art(patch);
});
}
fn attach_radio_art(rows: &mut [RadioSeedRow]) -> Vec<String> {
let mut missing = Vec::new();
for row in rows.iter_mut() {
if row.art_url.is_empty() {
continue;
}
let hit = crate::artwork_qt::cached_path(&row.art_url);
if hit.is_empty() {
missing.push(row.art_url.clone());
} else {
row.art_path = hit;
}
}
missing.sort();
missing.dedup();
missing
}
fn push_radio() {
let json = RADIO
.lock()
.ok()
.map(|rows| serde_json::to_string(&*rows).unwrap_or_else(|_| "[]".to_string()))
.unwrap_or_else(|| "[]".to_string());
crate::home_bridge::ui(move |mut b| {
b.as_mut()
.set_radio_stations_json(QString::from(json.as_str()));
});
}
// ===========================================================================
// Spotlight (Spotlight.slint + foryou.rs load_spotlight)
// ===========================================================================
#[derive(Clone, Default, Serialize)]
pub struct SpotlightDoc {
pub visible: bool,
#[serde(rename = "artistId")]
pub artist_id: String,
pub name: String,
pub category: String,
#[serde(rename = "artUrl")]
pub art_url: String,
#[serde(rename = "artPath")]
pub art_path: String,
#[serde(rename = "hasTopTracks")]
pub has_top_tracks: bool,
pub albums: Vec<HomeCard>,
}
static SPOTLIGHT: Mutex<Option<SpotlightDoc>> = Mutex::new(None);
/// One favourite artist highlighted (`foryou.rs::load_spotlight`): rotate
/// among the top 5 favourites by wall-clock seconds, fetch that artist's
/// page, take up to 6 releases preferring `album` then `live` / `ep-single` /
/// `compilation` in that order.
pub(crate) fn spawn_spotlight<A>(runtime: Arc<AppRuntime<A>>, favorites: Vec<Artist>)
where
A: FrontendAdapter + Send + Sync + 'static,
{
crate::spawn(async move {
let doc = load_spotlight(&runtime, &favorites).await;
publish_spotlight(doc);
});
}
async fn load_spotlight<A>(
runtime: &Arc<AppRuntime<A>>,
favorites: &[Artist],
) -> Option<SpotlightDoc>
where
A: FrontendAdapter + Send + Sync + 'static,
{
if favorites.is_empty() {
return None;
}
let pool = favorites.len().min(5);
let idx = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as usize % pool)
.unwrap_or(0);
let seed = &favorites[idx];
let page = match runtime.core().get_artist_page(seed.id, None).await {
Ok(page) => page,
Err(e) => {
log::warn!("[qbz-qt] spotlight artist page {} failed: {e}", seed.id);
return None;
}
};
let art_url = page
.images
.as_ref()
.and_then(|i| i.portrait.as_ref())
.map(|p| {
format!(
"https://static.qobuz.com/images/artists/covers/medium/{}.{}",
p.hash, p.format
)
})
.unwrap_or_default();
let mut seen: HashSet<String> = HashSet::new();
let mut albums: Vec<HomeCard> = Vec::new();
for want in ["album", "live", "ep-single", "compilation"] {
if albums.len() >= 6 {
break;
}
let Some(groups) = page.releases.as_ref() else {
break;
};
let Some(group) = groups.iter().find(|g| g.release_type == want) else {
continue;
};
for rel in &group.items {
if !seen.insert(rel.id.clone()) {
continue;
}
// `artist_qt::map_release` is the shared artist-page release
// mapper (rule 5: reuse over a second copy); only the two fields
// the rail transport adds on top are filled in here.
let card = crate::artist_qt::map_release(rel);
albums.push(HomeCard {
is_pinned: card.is_pinned,
is_favorite: card.is_favorite,
id: card.id,
title: card.title,
artist: if card.artist.is_empty() {
page.name.display.clone()
} else {
card.artist
},
artist_id: card.artist_id,
genre: card.genre,
year: card.year,
quality_tier: card.quality_tier,
quality_detail: card.quality_detail,
art_url: card.art_url,
..HomeCard::default()
});
if albums.len() >= 6 {
break;
}
}
}
Some(SpotlightDoc {
visible: true,
artist_id: seed.id.to_string(),
name: page.name.display.clone(),
category: page.artist_category.clone().unwrap_or_default(),
art_url,
art_path: String::new(),
has_top_tracks: page
.top_tracks
.as_ref()
.map(|t| !t.is_empty())
.unwrap_or(false),
albums,
})
}
fn publish_spotlight(doc: Option<SpotlightDoc>) {
let missing = {
let Ok(mut store) = SPOTLIGHT.lock() else {
return;
};
*store = doc;
store.as_mut().map(attach_spotlight_art).unwrap_or_default()
};
push_spotlight();
if missing.is_empty() {
return;
}
crate::spawn(async move {
crate::artwork_qt::download_missing(missing.clone()).await;
// Same in-place patch as the radio rail: the hero portrait and every
// album tile arrive inside ONE `foryouArtReady`, so the Spotlight
// document is never republished for artwork alone.
if let Ok(mut store) = SPOTLIGHT.lock() {
if let Some(doc) = store.as_mut() {
let _ = attach_spotlight_art(doc);
}
}
let mut patch: BTreeMap<String, String> = BTreeMap::new();
for url in missing {
let path = crate::artwork_qt::cached_path(&url);
collect_resolved(&mut patch, &url, &path);
}
emit_foryou_art(patch);
});
}
/// THE RULE (label_qt's `attach_doc_art`): every art-bearing list on the
/// document has to be listed here or its tiles stay blank forever.
fn attach_spotlight_art(doc: &mut SpotlightDoc) -> Vec<String> {
let mut missing = attach_card_art(&mut doc.albums);
if !doc.art_url.is_empty() {
let hit = crate::artwork_qt::cached_path(&doc.art_url);
if hit.is_empty() {
missing.push(doc.art_url.clone());
} else {
doc.art_path = hit;
}
}
missing.sort();
missing.dedup();
missing
}
fn push_spotlight() {
let json = SPOTLIGHT
.lock()
.ok()
.and_then(|doc| doc.as_ref().and_then(|d| serde_json::to_string(d).ok()))
.unwrap_or_else(|| "{}".to_string());
crate::home_bridge::ui(move |mut b| {
b.as_mut().set_spotlight_json(QString::from(json.as_str()));
});
}
/// Spotlight "play top tracks" (the primary circle button and the TOP TRACKS
/// card) — `media-action("artist", id, "play-top")` in Slint, which resolves
/// to `playback::play_artist_top_tracks`. `playback_qt::play_artist` is the
/// port of that path (top tracks, studio discography as the fallback).
pub(crate) fn spotlight_play_top_tracks(artist_id: String) {
let runtime = crate::app();
crate::spawn(async move {
if let Err(e) = crate::playback_qt::play_artist(&runtime, &artist_id).await {
log::warn!("[qbz-qt] spotlight top tracks {artist_id} failed: {e}");
}
});
}
// ---------------------------------------------------------------------------
// Radio start-up indicator (QbzHome.radioPending)
// ---------------------------------------------------------------------------
/// Publish "a radio is being built for this key" / "nothing is".
///
/// EVERY radio entry point below arms this on the way in and disarms it on
/// EVERY way out — the success path, the empty-result path and the error path
/// alike. A leaked key is worse than no indicator at all: it is a disc that
/// spins forever on a page the user is still looking at, and the only two
/// exits from a network call are "it worked" and "it did not".
///
/// `RadioGuard` is what makes that structural rather than a rule three
/// functions have to remember: it disarms in `Drop`, so an early `return` — or
/// a future cancelled before it finishes — cannot skip it.
fn set_radio_pending(key: &str) {
crate::home_bridge::publish_radio_pending(key.to_string());
}
/// Armed for the life of one radio build; disarms itself however the build
/// ends. Held across the `.await`s, so it is dropped on the same task.
struct RadioGuard;
impl RadioGuard {
fn arm(key: &str) -> Self {
set_radio_pending(key);
RadioGuard
}
}
impl Drop for RadioGuard {
fn drop(&mut self) {
set_radio_pending("");
}
}
/// Spotlight RADIO card and the artist page's "QBZ Radio" row —
/// `media-action("artist", id, "radio")` / `"radio-qbz"`, i.e. the SMART pool
/// builder (`qbz-radio`), not `/radio/artist`.
pub(crate) fn start_artist_radio(artist_id: String) {
let runtime = crate::app();
crate::spawn(async move {
// Armed BEFORE the parse: a bad id returns immediately and the guard's
// Drop clears it in the same turn, so the indicator never flickers,
// but the arming does not have to be duplicated after every early exit.
let _pending = RadioGuard::arm(&format!("artist:{artist_id}"));
let Ok(id) = artist_id.parse::<u64>() else {
log::warn!("[qbz-qt] smart radio: bad artist id {artist_id}");
return;
};
match runtime.core().create_smart_artist_radio(id).await {
Ok(tracks) if !tracks.is_empty() => play_flat(&runtime, tracks, 0, false).await,
Ok(_) => log::warn!("[qbz-qt] smart artist radio {id} returned no tracks"),
Err(e) => log::warn!("[qbz-qt] smart artist radio {id} failed: {e}"),
}
});
}
/// The "Qobuz Radio" arm of the ARTIST PAGE's radio dropdown —
/// `media-action("artist", id, "radio-qobuz")` in Slint
/// (`ArtistPageView.slint:457` -> `main.rs:12906` ->
/// `playback::play_artist_radio`). The plain Qobuz `/radio/artist` endpoint,
/// as opposed to `start_artist_radio` above, which is the SMART `qbz-radio`
/// pool builder wired to the dropdown's other row.
///
/// Its track objects are MINIMAL, exactly like the album radio's, so they go
/// through the same `enrich_radio_tracks` re-fetch — without it the queue rows
/// show no artist.
pub(crate) fn start_qobuz_artist_radio(artist_id: String) {
let runtime = crate::app();
crate::spawn(async move {
// Same key as the smart arm: they are two rows of ONE dropdown on ONE
// disc, and that disc is what spins.
let _pending = RadioGuard::arm(&format!("artist:{artist_id}"));
match runtime.core().get_radio_artist(&artist_id).await {
Ok(resp) => {
let tracks = enrich_radio_tracks(&runtime, resp.tracks.items).await;
if tracks.is_empty() {
log::warn!("[qbz-qt] qobuz artist radio {artist_id} returned no tracks");
return;
}
play_flat(&runtime, tracks, 0, false).await;
}
Err(e) => log::warn!("[qbz-qt] qobuz artist radio {artist_id} failed: {e}"),
}
});
}
/// Radio Stations tile — `media-action("album", id, "radio")`, the Qobuz
/// `/radio/album` endpoint. Its track objects are MINIMAL (no performer, no
/// album), so they are re-fetched by id exactly like `playback.rs::
/// enrich_radio_tracks` or the queue rows would show no artist.
pub(crate) fn start_album_radio(album_id: String) {
let runtime = crate::app();
crate::spawn(async move {
let _pending = RadioGuard::arm(&format!("album:{album_id}"));
match runtime.core().get_radio_album(&album_id).await {
Ok(resp) => {
let tracks = enrich_radio_tracks(&runtime, resp.tracks.items).await;
if tracks.is_empty() {
log::warn!("[qbz-qt] album radio {album_id} returned no tracks");
return;
}
play_flat(&runtime, tracks, 0, false).await;
}
Err(e) => log::warn!("[qbz-qt] album radio {album_id} failed: {e}"),
}
});
}
async fn enrich_radio_tracks(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<Track>,
) -> Vec<Track> {
let ids: Vec<u64> = tracks.iter().map(|t| t.id).collect();
if ids.is_empty() {
return tracks;
}
match runtime.core().get_tracks_batch(&ids).await {
Ok(full) if !full.is_empty() => full,
_ => tracks,
}
}
// ===========================================================================
// Qobuz mix landing page (mix.rs + MixView.slint)
// ===========================================================================
/// Title + description per mix kind (`mix.rs::mix_meta`). NOTE these are NOT
/// the four tile descriptions in `QobuzMixesRow.slint` — the Slint build uses
/// a different string for `weekly` and `top` on the detail page, and this is a
/// 1:1 port of the detail page's.
pub(crate) fn mix_meta(kind: &str) -> (&'static str, String) {
match kind {
"daily" => (
"DailyQ",
qbz_i18n::t("Elevate your day with a customized selection of music."),
),
"weekly" => ("WeeklyQ", qbz_i18n::t("A fresh mix every week.")),
"fav" => (
"FavQ",
qbz_i18n::t("A fresh shuffle from your personal library."),
),
"top" => ("TopQ", qbz_i18n::t("From your most-played playlists.")),
_ => ("Mix", String::new()),
}
}
#[derive(Clone, Default, Serialize)]
pub struct MixDoc {
/// "daily" | "weekly" | "fav" | "top" — the QML picks the header gradient
/// off this, matching the tile it was opened from.
pub kind: String,
pub title: String,
pub subtitle: String,
#[serde(rename = "trackCount")]
pub track_count: i32,
#[serde(rename = "totalDuration")]
pub total_duration: String,
pub loading: bool,
/// `qml/rows/TrackRow.qml` item objects (album_qt::TrackRow + artPath).
pub tracks: Vec<Value>,
}
#[derive(Default)]
struct MixState {
generation: u64,
doc: MixDoc,
/// The resolved tracks, kept so play-all / shuffle / play-from-here build
/// the queue without re-fetching (`mix.rs::CURRENT_MIX`).
queue: Vec<QueueTrack>,
}
static MIX: Mutex<Option<MixState>> = Mutex::new(None);
fn with_mix<R>(f: impl FnOnce(&mut MixState) -> R) -> Option<R> {
let mut guard = MIX.lock().ok()?;
Some(f(guard.get_or_insert_with(MixState::default)))
}
fn publish_mix() {
let json = MIX
.lock()
.ok()
.map(|s| match s.as_ref() {
Some(state) => serde_json::to_string(&state.doc).unwrap_or_else(|_| "{}".to_string()),
None => "{}".to_string(),
})
.unwrap_or_else(|| "{}".to_string());
crate::home_bridge::ui(move |mut b| {
b.as_mut().set_mix_json(QString::from(json.as_str()));
});
}
/// Open a Qobuz mix detail page. Same order as `label_qt::open_label`:
/// offline gate -> `nav_qt::record` (which already publishes `currentView`)
/// -> reset the document + raise `loading` -> fetch.
///
/// The RESET is the `mix.rs::reset_mix` hop: the title / subtitle / gradient
/// are known from the kind alone, so the page paints its header immediately
/// and only the list is pending — opening WeeklyQ after DailyQ never shows
/// DailyQ's header.
pub(crate) fn open_mix(kind: String) {
if crate::offline_fwd::engine().status().is_offline() {
return;
}
let kind = match kind.as_str() {
"daily" | "weekly" | "fav" | "top" => kind,
other => {
log::warn!("[qbz-qt] open_mix: unknown mix kind {other}");
return;
}
};
crate::nav_qt::record("mix");
let (title, subtitle) = mix_meta(&kind);
let generation = with_mix(|s| {
s.generation = s.generation.wrapping_add(1);
s.doc = MixDoc {
kind: kind.clone(),
title: title.to_string(),
subtitle,
loading: true,
..MixDoc::default()
};
s.queue.clear();
s.generation
});
let Some(generation) = generation else {
return;
};
publish_mix();
fetch_mix(generation, kind);
}
/// The header's refresh disc — re-load the mix that is on screen.
pub(crate) fn refresh_mix() {
let kind = MIX
.lock()
.ok()
.and_then(|s| s.as_ref().map(|state| state.doc.kind.clone()))
.unwrap_or_default();
if kind.is_empty() {
return;
}
let generation = with_mix(|s| {
s.generation = s.generation.wrapping_add(1);
s.doc.loading = true;
s.doc.tracks.clear();
s.doc.track_count = 0;
s.doc.total_duration = String::new();
s.queue.clear();
s.generation
});
let Some(generation) = generation else {
return;
};
publish_mix();
fetch_mix(generation, kind);
}
fn fetch_mix(generation: u64, kind: String) {
let runtime = crate::app();
crate::spawn(async move {
let tracks = load_mix(&runtime, &kind).await;
let rows: Vec<TrackRow> = tracks.iter().map(to_row).collect();
let queue: Vec<QueueTrack> = tracks.iter().map(to_queue_track).collect();
let count = rows.len() as i32;
let duration = total_duration(&tracks);
let stale = with_mix(|s| {
if s.generation != generation {
return true;
}
s.doc.tracks = rows.iter().map(track_row_value).collect();
s.doc.track_count = count;
s.doc.total_duration = duration;
s.doc.loading = false;
s.queue = queue;
false
})
.unwrap_or(true);
if stale {
return;
}
let missing = with_mix(attach_mix_art).unwrap_or_default();
publish_mix();
if missing.is_empty() {
return;
}
crate::artwork_qt::download_missing(missing).await;
let stale = with_mix(|s| {
if s.generation != generation {
return true;
}
let _ = attach_mix_art(s);
false
})
.unwrap_or(true);
if !stale {
publish_mix();
}
});
}
fn attach_mix_art(state: &mut MixState) -> Vec<String> {
let mut missing = Vec::new();
for row in state.doc.tracks.iter_mut() {
let url = row
.get("artUrl")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if url.is_empty() {
continue;
}
let hit = crate::artwork_qt::cached_path(&url);
if hit.is_empty() {
missing.push(url);
} else if let Some(obj) = row.as_object_mut() {
obj.insert("artPath".to_string(), Value::String(hit));
}
}
missing.sort();
missing.dedup();
missing
}
/// `TrackRow` as a `qml/rows/TrackRow.qml` item object, with the (initially
/// empty) `artPath` slot the 36px art cell reads (`label_qt::track_row_value`).
fn track_row_value(row: &TrackRow) -> Value {
let mut value = serde_json::to_value(row).unwrap_or_else(|_| Value::Object(Default::default()));
if let Some(obj) = value.as_object_mut() {
obj.insert("artPath".to_string(), Value::String(String::new()));
}
value
}
// --- mix track sourcing (mix.rs, verbatim minus the reco seeds) ------------
/// Even-spread sample of up to `n` ids (`mix.rs::pick_spread` / Tauri's
/// pickSpread): stride so the analysis seeds are not all clustered.
fn pick_spread(ids: &[u64], n: usize) -> Vec<u64> {
if ids.len() <= n {
return ids.to_vec();
}
(0..n).map(|i| ids[i * ids.len() / n]).collect()
}
/// The DailyQ / WeeklyQ listened-track seed: recent QOBUZ plays + Qobuz
/// favourites, deduped, capped at 120. Local / Plex / ephemeral recents carry
/// non-Qobuz ids and are excluded; a recents-only seed is frequently empty for
/// local-heavy users, so favourites guarantee a non-empty seed.
async fn mix_listened_seed_ids<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<u64>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let mut seeds: Vec<u64> = crate::recently_qt::load_tracks()
.into_iter()
.filter(|t| !matches!(t.source.as_str(), "local" | "plex" | "ephemeral"))
.filter_map(|t| t.id.parse::<u64>().ok())
.collect();
let mut seen: HashSet<u64> = seeds.iter().copied().collect();
for fav in favorite_tracks(runtime).await {
if seen.insert(fav.id) {
seeds.push(fav.id);
}
}
seeds.truncate(120);
seeds
}
/// Resolve up to 9 spread seeds into the `track_to_analysed` payload (the
/// PRIMARY DailyQ / WeeklyQ path, Tauri `buildSeeds`): `get_track` each,
/// extract `{track_id, artist_id, genre_id, label_id}` (artist = performer,
/// else composer; missing ids default to 0), drop any with `artist_id == 0`.
async fn build_tracks_to_analyse<A>(
runtime: &Arc<AppRuntime<A>>,
seeds: &[u64],
) -> Vec<TrackToAnalyse>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let mut analysed = Vec::new();
for id in pick_spread(seeds, 9) {
let Ok(track) = runtime.core().get_track(id).await else {
continue;
};
let artist_id = track
.performer
.as_ref()
.map(|a| a.id)
.or_else(|| track.composer.as_ref().map(|a| a.id))
.unwrap_or(0);
if artist_id == 0 {
continue;
}
analysed.push(TrackToAnalyse {
track_id: track.id,
artist_id,
genre_id: track
.album
.as_ref()
.and_then(|a| a.genre.as_ref())
.map(|g| g.id)
.unwrap_or(0),
label_id: track
.album
.as_ref()
.and_then(|a| a.label.as_ref())
.map(|l| l.id)
.unwrap_or(0),
});
}
analysed
}
async fn load_mix<A>(runtime: &Arc<AppRuntime<A>>, kind: &str) -> Vec<Track>
where
A: FrontendAdapter + Send + Sync + 'static,
{
match kind {
"daily" | "weekly" => {
let seeds = mix_listened_seed_ids(runtime).await;
if seeds.is_empty() {
log::warn!(
"[qbz-qt] mix '{kind}': no Qobuz seed tracks (recents + favorites empty) — empty mix"
);
return Vec::new();
}
let analysed = build_tracks_to_analyse(runtime, &seeds).await;
let limit = (50usize.saturating_sub(analysed.len())).max(1) as u32;
let tracks = match runtime
.core()
.get_dynamic_suggest_full(&seeds, &analysed, limit)
.await
{
Ok(tracks) if !tracks.is_empty() => tracks,
// FALLBACK (Tauri): retry with empty analysis + limit 50.
Ok(_) => runtime
.core()
.get_dynamic_suggest(&seeds, 50)
.await
.unwrap_or_default(),
Err(e) => {
log::warn!("[qbz-qt] mix '{kind}': dynamic/suggest failed: {e}");
Vec::new()
}
};
log::info!(
"[qbz-qt] mix '{kind}': {} seeds, {} analysed -> {} tracks",
seeds.len(),
analysed.len(),
tracks.len()
);
tracks
}
"fav" => {
let mut tracks = favorite_tracks(runtime).await;
shuffle(&mut tracks);
tracks
}
"top" => playlist_tracks(runtime).await,
_ => Vec::new(),
}
}
async fn favorite_tracks<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<Track>
where
A: FrontendAdapter + Send + Sync + 'static,
{
match runtime.core().get_favorites("tracks", 200, 0).await {
Ok(value) => qbz_models::lenient::parse_items_array(&value, "tracks", "mix favorite track"),
Err(e) => {
log::warn!("[qbz-qt] mix favorite tracks failed: {e}");
Vec::new()
}
}
}
async fn playlist_tracks<A>(runtime: &Arc<AppRuntime<A>>) -> Vec<Track>
where
A: FrontendAdapter + Send + Sync + 'static,
{
let Ok(playlists) = runtime.core().get_user_playlists().await else {
return Vec::new();
};
let mut out: Vec<Track> = Vec::new();
for pl in playlists.into_iter().take(5) {
if out.len() >= 100 {
break;
}
if let Ok(full) = runtime.core().get_playlist(pl.id).await {
if let Some(container) = full.tracks {
out.extend(container.items);
}
}
}
out.truncate(100);
out
}
/// Lightweight, deterministic-per-call shuffle (no rng dep) — `mix.rs`.
fn shuffle(tracks: &mut [Track]) {
let mut seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1)
| 1;
let n = tracks.len();
for i in (1..n).rev() {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let j = (seed % (i as u64 + 1)) as usize;
tracks.swap(i, j);
}
}
fn mmss(secs: u32) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
/// Human total duration: "1 h 23 min" or "23 min" (`mix.rs::total_duration`).
fn total_duration(tracks: &[Track]) -> String {
let secs: u64 = tracks.iter().map(|t| t.duration as u64).sum();
let mins = secs / 60;
if mins >= 60 {
let h = (mins / 60).to_string();
let m = (mins % 60).to_string();
qbz_i18n::t_args("{} h {} min", &[&h, &m])
} else {
qbz_i18n::t_args("{} min", &[&mins.to_string()])
}
}
/// `Track` -> the shared `album_qt::TrackRow` (rule 5: the row contract
/// `qml/rows/TrackRow.qml` already reads — the SAME struct the album, artist
/// and label pages serialize, which is the one that carries `album` for the
/// 220px Album column `showAlbum: true` draws).
///
/// `number` stays empty (the .slint mix rows carry none either — the index
/// comes from the delegate) and the work-grouping fields are album-detail
/// only, so they stay empty exactly as they do on the label page.
fn to_row(track: &Track) -> TrackRow {
let mut title = track.title.clone();
if let Some(v) = track.version.as_ref().filter(|v| !v.is_empty()) {
title = format!("{title} ({v})");
}
let bit_depth = track.maximum_bit_depth;
let sample_rate = track.maximum_sampling_rate;
TrackRow {
id: track.id.to_string(),
title,
artist: track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default(),
artist_id: track
.performer
.as_ref()
.map(|p| p.id.to_string())
.unwrap_or_default(),
album: track
.album
.as_ref()
.map(|a| a.title.clone())
.unwrap_or_default(),
album_id: track
.album
.as_ref()
.map(|a| a.id.clone())
.unwrap_or_default(),
duration: mmss(track.duration),
quality_tier: crate::home_qt::quality_tier_from_depth(bit_depth).to_string(),
quality_detail: crate::home_qt::quality_detail_from_parts(bit_depth, sample_rate),
// See `TrackRow::bit_depth` — the raw numbers ride with the row.
bit_depth,
sample_rate,
explicit: track.parental_warning,
// Track row art: full variant (best()) — the thumbnail down-tier
// was reverted after the 2026-08-15 owner smoke (contract 04 §3).
artwork_url: track
.album
.as_ref()
.and_then(|a| a.image.best().cloned())
.unwrap_or_default(),
is_favorite: crate::fav_cache_qt::is_favorite("track", &track.id.to_string()),
cache_status: if crate::offline_qt::is_cached(&track.id.to_string()) {
3
} else {
0
},
// NOT covered by `..default()`: that would leave every For You / mix row
// affirmatively claiming to be available, while `to_queue_track` two
// functions down reads the real value — the row and the queue built from
// the SAME track would disagree, which is the one thing the row model
// exists to prevent.
not_streamable: !track.is_streamable(),
..TrackRow::default()
}
}
/// `Track` -> `QueueTrack` (`playback_qt::catalog_queue_track`'s shape). The
/// mix is a FLAT list, so no container context is stamped — the now-playing
/// "playing from" falls back to each track's own album, 1:1 with the Slint
/// `play_tracks_ctx(.., None)` radio/mix path.
pub(crate) fn to_queue_track(track: &Track) -> QueueTrack {
let (album_id, album_title, album_artwork) = match track.album.as_ref() {
Some(album) => (
album.id.clone(),
album.title.clone(),
// Queue row / np bar feed: full variant (best()) — the thumbnail
// down-tier was reverted after the 2026-08-15 owner smoke (contract 04 §3).
album.image.best().cloned().unwrap_or_default(),
),
None => (String::new(), String::new(), String::new()),
};
// Register the full variant set for the immersive large-art feed (D2).
if let Some(album) = track.album.as_ref() {
if !album.id.is_empty() {
crate::artwork_qt::note_np_variants(&album.id, &album.image);
}
}
let album_key = if album_id.is_empty() {
None
} else {
Some(album_id)
};
QueueTrack {
id: track.id,
title: track.title.clone(),
version: track.version.clone(),
artist: track
.performer
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_default(),
album: album_title,
album_version: None,
duration_secs: track.duration as u64,
artwork_url: if album_artwork.is_empty() {
None
} else {
Some(album_artwork)
},
hires: track.hires,
bit_depth: track.maximum_bit_depth,
sample_rate: track.maximum_sampling_rate,
is_local: false,
album_id: album_key.clone(),
artist_id: track.performer.as_ref().map(|p| p.id),
// §3.1: absence is resolved in ONE place. `is_streamable()` reads a
// missing key as AVAILABLE — only an explicit `false` marks a pull.
streamable: track.is_streamable(),
source: Some("qobuz".to_string()),
parental_warning: track.parental_warning,
source_item_id_hint: album_key,
context_kind: None,
context_id: None,
isrc: track.isrc.clone(),
recording_mbid: None,
}
}
// --- mix playback ---------------------------------------------------------
fn mix_queue() -> Vec<QueueTrack> {
MIX.lock()
.ok()
.and_then(|s| s.as_ref().map(|state| state.queue.clone()))
.unwrap_or_default()
}
/// Header Play — the whole mix becomes the queue, from the top.
pub(crate) fn mix_play_all() {
play_queue_from(0, false);
}
/// Header Shuffle — a fresh random order that does NOT mutate the displayed
/// list (`mix.rs::shuffled_tracks`).
pub(crate) fn mix_shuffle() {
let mut queue = mix_queue();
if queue.is_empty() {
return;
}
shuffle_queue(&mut queue);
let runtime = crate::app();
crate::spawn(async move {
if let Err(e) = crate::playback_qt::play_track_list(&runtime, queue, 0, false).await {
log::warn!("[qbz-qt] mix shuffle failed: {e}");
}
});
}
/// A row click / row Play — the mix becomes the queue anchored at that track
/// (`mix.rs::index_of` + play-from-here).
pub(crate) fn mix_play_track(track_id: String) {
let index = mix_queue()
.iter()
.position(|t| t.id.to_string() == track_id)
.unwrap_or(0);
play_queue_from(index, false);
}
/// A row's ⋯ menu: "next" | "later" | "queue".
pub(crate) fn mix_enqueue_track(track_id: String, mode: String) {
let Some(track) = mix_queue()
.into_iter()
.find(|t| t.id.to_string() == track_id)
else {
return;
};
let runtime = crate::app();
crate::spawn(async move {
if let Err(e) =
crate::playback_qt::enqueue_track_list_mode(&runtime, vec![track], &mode).await
{
log::warn!("[qbz-qt] mix enqueue failed: {e}");
}
});
}
fn play_queue_from(index: usize, shuffle: bool) {
let queue = mix_queue();
if queue.is_empty() {
return;
}
let runtime = crate::app();
crate::spawn(async move {
if let Err(e) = crate::playback_qt::play_track_list(&runtime, queue, index, shuffle).await {
log::warn!("[qbz-qt] mix play failed: {e}");
}
});
}
/// Shuffle over the already-built queue rows (the mix's own xorshift, applied
/// to `QueueTrack` instead of `Track`).
fn shuffle_queue(tracks: &mut [QueueTrack]) {
let mut seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1)
| 1;
let n = tracks.len();
for i in (1..n).rev() {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let j = (seed % (i as u64 + 1)) as usize;
tracks.swap(i, j);
}
}
/// Start a flat radio result as the queue (Slint `play_radio_response` ->
/// `play_tracks(.., 0)`), reusing the port's shared list-play path.
async fn play_flat(
runtime: &Arc<AppRuntime<LoggingAdapter>>,
tracks: Vec<Track>,
start: usize,
shuffle: bool,
) {
let queue: Vec<QueueTrack> = tracks.iter().map(to_queue_track).collect();
if queue.is_empty() {
return;
}
if let Err(e) = crate::playback_qt::play_track_list(runtime, queue, start, shuffle).await {
log::warn!("[qbz-qt] radio play failed: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pick_spread_strides_and_never_overruns() {
let ids: Vec<u64> = (0..100).collect();
let picked = pick_spread(&ids, 9);
assert_eq!(picked.len(), 9);
assert_eq!(picked[0], 0);
// Strictly increasing, in range.
for w in picked.windows(2) {
assert!(w[0] < w[1]);
}
assert!(picked.iter().all(|id| *id < 100));
// Fewer ids than asked for -> the whole list, unchanged.
assert_eq!(pick_spread(&ids[..4], 9), ids[..4].to_vec());
}
#[test]
fn mix_meta_covers_the_four_kinds_and_degrades() {
for kind in ["daily", "weekly", "fav", "top"] {
let (title, subtitle) = mix_meta(kind);
assert!(!title.is_empty(), "{kind} has no title");
assert!(!subtitle.is_empty(), "{kind} has no subtitle");
}
assert_eq!(mix_meta("nope").0, "Mix");
}
#[test]
fn build_radio_dedupes_caps_and_drops_non_qobuz_sources() {
let recents = vec![
crate::recently_qt::RecentAlbum {
id: "a1".into(),
title: "One".into(),
artist: "X".into(),
source: "qobuz".into(),
..Default::default()
},
// Same album again — deduped.
crate::recently_qt::RecentAlbum {
id: "a1".into(),
title: "One".into(),
artist: "X".into(),
source: String::new(),
..Default::default()
},
// Plex ids cannot resolve against /radio/album.
crate::recently_qt::RecentAlbum {
id: "plex-7".into(),
title: "Nope".into(),
artist: "Y".into(),
source: "plex".into(),
..Default::default()
},
// Legacy entry with no source is treated as Qobuz.
crate::recently_qt::RecentAlbum {
id: "a2".into(),
title: "Two".into(),
artist: "Z".into(),
source: String::new(),
..Default::default()
},
];
let rows = build_radio(&recents, &[]);
assert_eq!(
rows.iter().map(|r| r.album_id.as_str()).collect::<Vec<_>>(),
vec!["a1", "a2"]
);
}
}