feat(reco): redesign Recommendations tab — artist/album-centric + LB playlists

Reworks the 4th Discover tab per owner feedback (the track-heavy v1 surfaced
already-heard artists). Verified against three sources that Last.fm has NO
recommendations API (getRecommendedArtists removed; web is PJAX HTML behind the
website cookie, which the API session key cannot reach) — so recommendations are
replicated from the documented similarity recipe, and ListenBrainz provides the
genuinely clean personalized feeds.

New lineup (each row paints progressively as its builder resolves):
- Recommended Artists (Last.fm artist.getSimilar seeded by last-month top, minus
  the heard set; "Similar to X, Y" subtitle on the artist card).
- Recommended Albums (Last.fm artist.getTopAlbums minus your scrobbled albums).
- Fresh Releases (ListenBrainz, from artists you follow).
- Weekly Exploration + Weekly Jams (ListenBrainz curated playlists, by source_patch).
- Deep-cut albums from artists you know (Qobuz catalog, not heard in-app).
- Cold-start fallback: Qobuz editorial top albums + artists.

The "not heard" filter is now ARTIST-level (the v1 bug derived artists from
unheard TRACKS) and compares against the LOCAL reco history + a light Last.fm/LB
top-set, not a full history sweep. Album validation (UPC -> fuzzy text) added.
qbz-integrations gains Last.fm get_artist_top_albums/get_user_top_albums and
ListenBrainz get_created_for_playlists/get_playlist_tracks/get_fresh_releases.

NOTE: the Cloudflare proxy was switched to allow ALL Last.fm read methods
(anything with ".get"), so future reads need NO proxy redeploy — but the live
proxy must be redeployed ONCE with this change or all Last.fm reads keep 400ing.

cargo check -p qbz green.

cc
This commit is contained in:
vicrodh committed 2026-06-27 08:34:24 -06:00
1 parent ec98245364
commit 0347e333a8
16 files changed
+1452 -653

No files matched your search

+245 -363
View File
@@ -1,64 +1,40 @@
//! Per-carousel candidate generation, blending, filtering, validation, rotation. //! Per-row candidate generation, blending, filtering, Qobuz validation, rotation.
//!
//! The documented Last.fm "artist discovery" recipe (api-evangelist/lastfm
//! arazzo workflow): top artists -> artist.getSimilar -> top albums. There is no
//! recommendation endpoint, so recommendations are replicated from similarity.
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::sync::Mutex; use std::sync::Mutex;
use futures_util::stream::{self, StreamExt}; use futures_util::stream::{self, StreamExt};
use qbz_integrations::MusicBrainzClient;
use qbz_models::Album; use qbz_models::Album;
use crate::cache::RecoCache; use crate::cache::RecoCache;
use crate::matching::normalize; use crate::matching::normalize;
use crate::types::{ use crate::types::{
AlbumReco, ArtistCandidate, ArtistReco, ExternalCarousels, RecoSource, TrackCandidate, TrackReco, AlbumCandidate, AlbumReco, ArtistCandidate, ArtistReco, ExtHistory, RecoSource, TrackCandidate,
TrackReco,
}; };
use crate::validate::{validate_artist, validate_track}; use crate::validate::{build_album_reco, validate_album, validate_artist, validate_track};
use crate::{RecoCatalog, RecoInputs}; use crate::{RecoCatalog, RecoInputs};
const DISPLAY_CAP: usize = 20; const DISPLAY_CAP: usize = 20;
const PLAYLIST_CAP: usize = 30;
const VALIDATE_CONCURRENCY: usize = 6; const VALIDATE_CONCURRENCY: usize = 6;
const ARTIST_SEEDS: usize = 5; const ARTIST_SEEDS: usize = 6;
const SIMILAR_PER_SEED: u32 = 15; const SIMILAR_PER_SEED: u32 = 12;
const KNOWN_ARTISTS_PER_BUILD: usize = 8; const KNOWN_ARTISTS_PER_BUILD: usize = 8;
const DEEP_CUT_SKIP_TOP: usize = 3;
type Cache<'a> = Option<&'a Mutex<RecoCache>>; type Cache<'a> = Option<&'a Mutex<RecoCache>>;
/// Normalized "artist|title" key for the listened/scrobbled exclusion sets.
fn track_key(artist: &str, title: &str) -> String { fn track_key(artist: &str, title: &str) -> String {
format!("{}|{}", normalize(artist), normalize(title)) format!("{}|{}", normalize(artist), normalize(title))
} }
fn album_key(artist: &str, album: &str) -> String {
/// External listening signal (normalized) used to filter "not heard" rows. format!("{}|{}", normalize(artist), normalize(album))
struct ExtHistory {
/// Artists the user has listened to (Last.fm/LB) — excludes C1.
artist_names: HashSet<String>,
/// Tracks the user has scrobbled — excludes C2 / C4.
track_keys: HashSet<String>,
} }
/// Interleave candidate groups round-robin so every connected source is fairly
/// represented (the Option-B blend).
fn round_robin<T>(groups: Vec<Vec<T>>) -> Vec<T> {
let mut iters: Vec<std::vec::IntoIter<T>> = groups.into_iter().map(|g| g.into_iter()).collect();
let mut out = Vec::new();
loop {
let mut any = false;
for it in iters.iter_mut() {
if let Some(x) = it.next() {
out.push(x);
any = true;
}
}
if !any {
break;
}
}
out
}
/// Rotate the pool by the daily seed, then take the display cap — gives "change"
/// across days without re-hitting the APIs (the pool is cache-backed).
fn rotate_take<T>(mut pool: Vec<T>, seed: u64, take: usize) -> Vec<T> { fn rotate_take<T>(mut pool: Vec<T>, seed: u64, take: usize) -> Vec<T> {
if pool.is_empty() { if pool.is_empty() {
return pool; return pool;
@@ -69,74 +45,19 @@ fn rotate_take<T>(mut pool: Vec<T>, seed: u64, take: usize) -> Vec<T> {
pool pool
} }
fn map_album_reco(a: &Album) -> AlbumReco { // ── Validation pools (concurrent, blend-ordered, deduped) ───────────────────
let year = a
.release_date_original
.as_deref()
.and_then(|s| s.get(..4).map(|y| y.to_string()))
.unwrap_or_default();
let quality_tier = match a.maximum_bit_depth {
Some(d) if d >= 24 => "hires",
Some(_) => "cd",
None => "",
}
.to_string();
let quality_label = match (a.maximum_bit_depth, a.maximum_sampling_rate) {
(Some(bd), Some(sr)) => format!("{}-bit / {} kHz", bd, sr),
_ => String::new(),
};
AlbumReco {
qobuz_album_id: a.id.clone(),
title: a.title.clone(),
artist: a.artist.name.clone(),
artist_id: a.artist.id.to_string(),
year,
quality_tier,
quality_label,
artwork_url: a.image.best().cloned().unwrap_or_default(),
}
}
/// Validate a track-candidate pool concurrently, preserving blend order, deduped
/// by Qobuz track id.
async fn validate_track_pool(
catalog: &dyn RecoCatalog,
mb: &MusicBrainzClient,
cache: Cache<'_>,
cands: Vec<TrackCandidate>,
) -> Vec<TrackReco> {
let resolved: Vec<Option<TrackReco>> = stream::iter(
cands
.into_iter()
.map(|cand| async move { validate_track(catalog, mb, cache, &cand).await }),
)
.buffered(VALIDATE_CONCURRENCY)
.collect()
.await;
let mut seen = HashSet::new();
resolved
.into_iter()
.flatten()
.filter(|r| seen.insert(r.qobuz_track_id))
.collect()
}
/// Validate an artist-candidate pool concurrently, deduped by Qobuz artist id.
async fn validate_artist_pool( async fn validate_artist_pool(
catalog: &dyn RecoCatalog, catalog: &dyn RecoCatalog,
cache: Cache<'_>, cache: Cache<'_>,
cands: Vec<ArtistCandidate>, cands: Vec<ArtistCandidate>,
) -> Vec<ArtistReco> { ) -> Vec<ArtistReco> {
let resolved: Vec<Option<ArtistReco>> = stream::iter( let resolved: Vec<Option<ArtistReco>> = stream::iter(
cands cands.into_iter().map(|cand| async move { validate_artist(catalog, cache, &cand).await }),
.into_iter()
.map(|cand| async move { validate_artist(catalog, cache, &cand).await }),
) )
.buffered(VALIDATE_CONCURRENCY) .buffered(VALIDATE_CONCURRENCY)
.collect() .collect()
.await; .await;
let mut seen = HashSet::new(); let mut seen = HashSet::new();
resolved resolved
.into_iter() .into_iter()
@@ -145,45 +66,57 @@ async fn validate_artist_pool(
.collect() .collect()
} }
pub async fn build(inputs: RecoInputs<'_>) -> ExternalCarousels { async fn validate_album_pool(
let has_external = inputs.lastfm.is_some() || inputs.listenbrainz.is_some(); catalog: &dyn RecoCatalog,
if !has_external { cache: Cache<'_>,
return build_editorial_fallback(&inputs).await; cands: Vec<AlbumCandidate>,
} ) -> Vec<AlbumReco> {
let resolved: Vec<Option<AlbumReco>> = stream::iter(
let history = gather_external_history(&inputs).await; cands.into_iter().map(|cand| async move { validate_album(catalog, cache, &cand).await }),
)
// Shared ListenBrainz CF (the personalized recommender), kept to the .buffered(VALIDATE_CONCURRENCY)
// never-listened slice — feeds BOTH C1 (its artists) and C2 (its tracks). .collect()
let lb_cf_unheard = fetch_lb_cf_unheard(&inputs).await; .await;
let mut seen = HashSet::new();
let (c1, c2, c3, c4) = tokio::join!( resolved
build_similar_artists(&inputs, &history, &lb_cf_unheard), .into_iter()
build_similar_tracks(&inputs, &history, &lb_cf_unheard), .flatten()
build_rediscover_tracks(&inputs, &history), .filter(|r| seen.insert(r.qobuz_album_id.clone()))
build_deep_cut_tracks(&inputs, &history), .collect()
);
ExternalCarousels {
editorial_fallback: false,
similar_artists: c1,
similar_tracks: c2,
rediscover_tracks: c3,
deep_cut_tracks: c4,
top_albums: Vec::new(),
top_artists: Vec::new(),
}
} }
async fn gather_external_history(inputs: &RecoInputs<'_>) -> ExtHistory { async fn validate_track_pool(
catalog: &dyn RecoCatalog,
mb: &qbz_integrations::MusicBrainzClient,
cache: Cache<'_>,
cands: Vec<TrackCandidate>,
) -> Vec<TrackReco> {
let resolved: Vec<Option<TrackReco>> = stream::iter(
cands.into_iter().map(|cand| async move { validate_track(catalog, mb, cache, &cand).await }),
)
.buffered(VALIDATE_CONCURRENCY)
.collect()
.await;
let mut seen = HashSet::new();
resolved
.into_iter()
.flatten()
.filter(|r| seen.insert(r.qobuz_track_id))
.collect()
}
// ── Shared external history ─────────────────────────────────────────────────
pub async fn gather_history(inputs: &RecoInputs<'_>) -> ExtHistory {
let mut artist_names = HashSet::new(); let mut artist_names = HashSet::new();
let mut track_keys = HashSet::new(); let mut track_keys = HashSet::new();
let mut album_keys = HashSet::new();
if let Some(lf) = &inputs.lastfm { if let Some(lf) = &inputs.lastfm {
let (tops, recents, loved) = tokio::join!( let (tops, recents, albums) = tokio::join!(
lf.client.get_top_artists(&lf.username, "overall", 200), lf.client.get_top_artists(&lf.username, "overall", 300),
lf.client.get_recent_tracks(&lf.username, 200, 1), lf.client.get_recent_tracks(&lf.username, 200, 1),
lf.client.get_loved_tracks(&lf.username, 100), lf.client.get_user_top_albums(&lf.username, "overall", 300, 1),
); );
for a in tops.unwrap_or_default() { for a in tops.unwrap_or_default() {
artist_names.insert(normalize(&a.name)); artist_names.insert(normalize(&a.name));
@@ -192,302 +125,259 @@ async fn gather_external_history(inputs: &RecoInputs<'_>) -> ExtHistory {
artist_names.insert(normalize(&t.artist)); artist_names.insert(normalize(&t.artist));
track_keys.insert(track_key(&t.artist, &t.name)); track_keys.insert(track_key(&t.artist, &t.name));
} }
for t in loved.unwrap_or_default() { for al in albums.unwrap_or_default() {
artist_names.insert(normalize(&t.artist)); artist_names.insert(normalize(&al.artist));
track_keys.insert(track_key(&t.artist, &t.name)); album_keys.insert(album_key(&al.artist, &al.name));
} }
} }
if let Some(lb) = &inputs.listenbrainz { if let Some(lb) = &inputs.listenbrainz {
let listens = lb let listens = lb.client.get_recent_listens(&lb.username, 1000).await.unwrap_or_default();
.client
.get_recent_listens(&lb.username, 1000)
.await
.unwrap_or_default();
for l in listens { for l in listens {
artist_names.insert(normalize(&l.artist_name)); artist_names.insert(normalize(&l.artist_name));
track_keys.insert(track_key(&l.artist_name, &l.track_name)); track_keys.insert(track_key(&l.artist_name, &l.track_name));
} }
} }
ExtHistory { ExtHistory {
artist_names, artist_names,
track_keys, track_keys,
album_keys,
} }
} }
/// ListenBrainz CF recommendations, never-listened slice, hydrated to names. // ── Recommended Artists (Last.fm similar of recent top, not heard) ──────────
async fn fetch_lb_cf_unheard(
inputs: &RecoInputs<'_>, pub async fn build_rec_artists(inputs: &RecoInputs<'_>, history: &ExtHistory) -> Vec<ArtistReco> {
) -> Vec<qbz_integrations::listenbrainz::LbRecordingMeta> { let Some(lf) = &inputs.lastfm else {
let Some(lb) = &inputs.listenbrainz else {
return Vec::new(); return Vec::new();
}; };
let recs = lb let seeds: Vec<String> = lf
.client .client
.get_cf_recommendations(&lb.username, 200) .get_top_artists(&lf.username, "1month", 12)
.await
.unwrap_or_default();
let unheard_mbids: Vec<String> = recs
.into_iter()
.filter(|r| r.latest_listened_at.is_none())
.map(|r| r.recording_mbid)
.filter(|m| !m.is_empty())
.take(80)
.collect();
if unheard_mbids.is_empty() {
return Vec::new();
}
lb.client
.get_metadata_recordings(&unheard_mbids)
.await .await
.unwrap_or_default() .unwrap_or_default()
} .into_iter()
.take(ARTIST_SEEDS)
.map(|a| a.name)
.collect();
let seeds_norm: HashSet<String> = seeds.iter().map(|s| normalize(s)).collect();
// ── C1 — Similar artists you haven't heard ───────────────────────────────── let sim_results: Vec<(String, Vec<qbz_integrations::lastfm::LastFmSimilarArtist>)> =
async fn build_similar_artists( stream::iter(seeds.into_iter().map(|seed| {
inputs: &RecoInputs<'_>,
history: &ExtHistory,
lb_cf_unheard: &[qbz_integrations::listenbrainz::LbRecordingMeta],
) -> Vec<ArtistReco> {
let mut lastfm_group: Vec<ArtistCandidate> = Vec::new();
if let Some(lf) = &inputs.lastfm {
let seeds = lf
.client
.get_top_artists(&lf.username, "6month", 10)
.await
.unwrap_or_default();
let seed_names: Vec<String> = seeds.into_iter().take(ARTIST_SEEDS).map(|a| a.name).collect();
let sims = stream::iter(seed_names.into_iter().map(|name| {
let lf = lf; let lf = lf;
async move { async move {
lf.client let sims = lf
.get_similar_artists(&name, SIMILAR_PER_SEED) .client
.get_similar_artists(&seed, SIMILAR_PER_SEED)
.await .await
.unwrap_or_default() .unwrap_or_default();
(seed, sims)
} }
})) }))
.buffered(4) .buffered(4)
.collect::<Vec<_>>() .collect()
.await; .await;
for group in sims {
for s in group { // Aggregate candidate -> the seeds that surfaced it (for the subtitle).
lastfm_group.push(ArtistCandidate { let mut agg: HashMap<String, (String, Vec<String>, f32)> = HashMap::new();
name: s.name, for (seed, sims) in sim_results {
source: RecoSource::LastFm, for s in sims {
score: s.match_score as f32, let nk = normalize(&s.name);
}); if nk.is_empty() || history.artist_names.contains(&nk) || seeds_norm.contains(&nk) {
continue;
} }
let e = agg.entry(nk).or_insert((s.name.clone(), Vec::new(), 0.0));
if !e.1.contains(&seed) {
e.1.push(seed.clone());
}
e.2 = e.2.max(s.match_score as f32);
} }
} }
let mut lb_group: Vec<ArtistCandidate> = Vec::new(); let mut candidates: Vec<ArtistCandidate> = agg
let total = lb_cf_unheard.len().max(1) as f32; .into_values()
for (i, meta) in lb_cf_unheard.iter().enumerate() { .map(|(name, seeds, score)| {
if meta.artist_name.is_empty() { let subtitle = if seeds.is_empty() {
continue; String::new()
} } else {
lb_group.push(ArtistCandidate { format!(
name: meta.artist_name.clone(), "Similar to {}",
source: RecoSource::ListenBrainz, seeds.iter().take(2).cloned().collect::<Vec<_>>().join(", ")
score: 1.0 - (i as f32 / total), )
}); };
} ArtistCandidate {
name,
// Blend, drop already-heard, dedup by normalized name. source: RecoSource::LastFm,
let mut seen: HashSet<String> = HashSet::new(); score,
let candidates: Vec<ArtistCandidate> = round_robin(vec![lb_group, lastfm_group]) subtitle,
.into_iter() }
.filter(|c| {
let n = normalize(&c.name);
!n.is_empty() && !history.artist_names.contains(&n) && seen.insert(n)
}) })
.take(45)
.collect(); .collect();
candidates.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
candidates.truncate(45);
let pool = validate_artist_pool(inputs.catalog, inputs.cache, candidates).await; let pool = validate_artist_pool(inputs.catalog, inputs.cache, candidates).await;
rotate_take(pool, inputs.rotation_seed, DISPLAY_CAP) rotate_take(pool, inputs.rotation_seed, DISPLAY_CAP)
} }
// ── C2 — Similar tracks you haven't heard ────────────────────────────────── // ── Recommended Albums (Last.fm: your artists' top albums, not scrobbled) ───
async fn build_similar_tracks(
inputs: &RecoInputs<'_>,
history: &ExtHistory,
lb_cf_unheard: &[qbz_integrations::listenbrainz::LbRecordingMeta],
) -> Vec<TrackReco> {
// LB: CF recordings (already never-listened).
let total = lb_cf_unheard.len().max(1) as f32;
let lb_group: Vec<TrackCandidate> = lb_cf_unheard
.iter()
.enumerate()
.filter(|(_, m)| !m.recording_name.is_empty() && !m.artist_name.is_empty())
.map(|(i, m)| TrackCandidate {
artist: m.artist_name.clone(),
title: m.recording_name.clone(),
album: m.release_name.clone(),
duration_ms: None,
isrc: None,
recording_mbid: Some(m.recording_mbid.clone()).filter(|s| !s.is_empty()),
source: RecoSource::ListenBrainz,
score: 1.0 - (i as f32 / total),
})
.collect();
// Last.fm: track.getSimilar seeded by loved tracks. pub async fn build_rec_albums(inputs: &RecoInputs<'_>, history: &ExtHistory) -> Vec<AlbumReco> {
let mut lastfm_group: Vec<TrackCandidate> = Vec::new();
if let Some(lf) = &inputs.lastfm {
let loved = lf
.client
.get_loved_tracks(&lf.username, 30)
.await
.unwrap_or_default();
let seeds: Vec<(String, String)> = loved
.into_iter()
.take(8)
.map(|t| (t.artist, t.name))
.collect();
let sims = stream::iter(seeds.into_iter().map(|(artist, title)| {
let lf = lf;
async move {
lf.client
.get_similar_tracks(&artist, &title, 10)
.await
.unwrap_or_default()
}
}))
.buffered(4)
.collect::<Vec<_>>()
.await;
for group in sims {
for s in group {
lastfm_group.push(TrackCandidate {
artist: s.artist,
title: s.name,
album: None,
duration_ms: None,
isrc: None,
recording_mbid: None,
source: RecoSource::LastFm,
score: s.match_score as f32,
});
}
}
}
let mut seen: HashSet<String> = HashSet::new();
let candidates: Vec<TrackCandidate> = round_robin(vec![lb_group, lastfm_group])
.into_iter()
.filter(|c| {
let k = track_key(&c.artist, &c.title);
!history.track_keys.contains(&k) && seen.insert(k)
})
.take(50)
.collect();
let pool = validate_track_pool(inputs.catalog, inputs.musicbrainz, inputs.cache, candidates).await;
rotate_take(pool, inputs.rotation_seed, DISPLAY_CAP)
}
// ── C3 — Listened but not recently (Last.fm long-term minus recent) ─────────
async fn build_rediscover_tracks(inputs: &RecoInputs<'_>, _history: &ExtHistory) -> Vec<TrackReco> {
let Some(lf) = &inputs.lastfm else { let Some(lf) = &inputs.lastfm else {
return Vec::new(); return Vec::new();
}; };
let (long_term, recent) = tokio::join!( let artists: Vec<String> = lf
lf.client.get_top_tracks(&lf.username, "12month", 60), .client
lf.client.get_top_tracks(&lf.username, "1month", 60), .get_top_artists(&lf.username, "1month", 10)
); .await
let recent_keys: HashSet<String> = recent
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.map(|t| track_key(&t.artist, &t.name)) .take(KNOWN_ARTISTS_PER_BUILD)
.map(|a| a.name)
.collect(); .collect();
let per_artist: Vec<(String, Vec<qbz_integrations::lastfm::LastFmAlbum>)> =
stream::iter(artists.into_iter().map(|name| {
let lf = lf;
async move {
let albums = lf.client.get_artist_top_albums(&name, 6).await.unwrap_or_default();
(name, albums)
}
}))
.buffered(4)
.collect()
.await;
let mut seen: HashSet<String> = HashSet::new(); let mut seen: HashSet<String> = HashSet::new();
let candidates: Vec<TrackCandidate> = long_term let mut candidates: Vec<AlbumCandidate> = Vec::new();
.unwrap_or_default() for (artist, albums) in per_artist {
.into_iter() for al in albums {
.filter_map(|t| { let k = album_key(&al.artist, &al.name);
let k = track_key(&t.artist, &t.name); if history.album_keys.contains(&k) || !seen.insert(k) {
if recent_keys.contains(&k) || !seen.insert(k) { continue;
return None;
} }
Some(TrackCandidate { candidates.push(AlbumCandidate {
artist: t.artist, artist: al.artist.clone(),
title: t.name, title: al.name,
album: t.album, upc: None,
duration_ms: None,
isrc: None,
recording_mbid: None,
source: RecoSource::LastFm, source: RecoSource::LastFm,
score: 0.0, score: al.playcount as f32,
}) subtitle: format!("From {} — you haven't heard this one", artist),
}) });
.take(50) }
.collect(); }
candidates.truncate(50);
let pool = validate_track_pool(inputs.catalog, inputs.musicbrainz, inputs.cache, candidates).await; let pool = validate_album_pool(inputs.catalog, inputs.cache, candidates).await;
rotate_take(pool, inputs.rotation_seed, DISPLAY_CAP) rotate_take(pool, inputs.rotation_seed, DISPLAY_CAP)
} }
// ── C4 — From artists you know but not scrobbled (deep cuts) ──────────────── // ── Fresh Releases (ListenBrainz, from artists you follow) ──────────────────
async fn build_deep_cut_tracks(inputs: &RecoInputs<'_>, history: &ExtHistory) -> Vec<TrackReco> {
pub async fn build_fresh_releases(inputs: &RecoInputs<'_>) -> Vec<AlbumReco> {
let Some(lb) = &inputs.listenbrainz else {
return Vec::new();
};
let releases = lb.client.get_fresh_releases(&lb.username, 30).await.unwrap_or_default();
let candidates: Vec<AlbumCandidate> = releases
.into_iter()
.filter(|r| !r.release_name.is_empty() && !r.artist_credit_name.is_empty())
.take(50)
.map(|r| AlbumCandidate {
artist: r.artist_credit_name,
title: r.release_name,
upc: None,
source: RecoSource::ListenBrainz,
score: 0.0,
subtitle: r
.release_date
.map(|d| format!("New release · {}", d))
.unwrap_or_else(|| "New release".to_string()),
})
.collect();
let pool = validate_album_pool(inputs.catalog, inputs.cache, candidates).await;
rotate_take(pool, inputs.rotation_seed, DISPLAY_CAP)
}
// ── Weekly playlists (ListenBrainz curated: exploration / jams) ─────────────
pub async fn build_weekly(inputs: &RecoInputs<'_>, source_patch: &str) -> Vec<TrackReco> {
let Some(lb) = &inputs.listenbrainz else {
return Vec::new();
};
let playlists = lb
.client
.get_created_for_playlists(&lb.username, 50)
.await
.unwrap_or_default();
// Newest playlist matching the source_patch (created_at desc).
let chosen = playlists
.into_iter()
.filter(|p| p.source_patch.as_deref() == Some(source_patch))
.max_by(|a, b| a.created_at.cmp(&b.created_at));
let Some(meta) = chosen else {
return Vec::new();
};
let tracks = lb
.client
.get_playlist_tracks(&meta.playlist_mbid)
.await
.unwrap_or_default();
let candidates: Vec<TrackCandidate> = tracks
.into_iter()
.filter(|t| !t.title.is_empty() && !t.artist_name.is_empty())
.map(|t| TrackCandidate {
artist: t.artist_name,
title: t.title,
album: t.release_name,
duration_ms: None,
isrc: None,
recording_mbid: t.recording_mbid,
source: RecoSource::ListenBrainz,
score: 0.0,
})
.collect();
let pool =
validate_track_pool(inputs.catalog, inputs.musicbrainz, inputs.cache, candidates).await;
pool.into_iter().take(PLAYLIST_CAP).collect()
}
// ── Deep-cut albums from artists you know (Qobuz catalog, not heard) ────────
pub async fn build_deep_cut_albums(inputs: &RecoInputs<'_>) -> Vec<AlbumReco> {
if inputs.local.known_artist_ids.is_empty() { if inputs.local.known_artist_ids.is_empty() {
return Vec::new(); return Vec::new();
} }
// Rotate which known artists we mine today.
let mut ids: Vec<u64> = inputs.local.known_artist_ids.iter().copied().collect(); let mut ids: Vec<u64> = inputs.local.known_artist_ids.iter().copied().collect();
ids.sort_unstable(); ids.sort_unstable();
let ids = rotate_take(ids, inputs.rotation_seed, KNOWN_ARTISTS_PER_BUILD); let ids = rotate_take(ids, inputs.rotation_seed, KNOWN_ARTISTS_PER_BUILD);
let per_artist = stream::iter(ids.into_iter().map(|id| { let per_artist: Vec<Vec<Album>> = stream::iter(ids.into_iter().map(|id| {
let catalog = inputs.catalog; let catalog = inputs.catalog;
async move { catalog.artist_top_tracks(id, 15).await } async move { catalog.artist_albums(id, 12).await }
})) }))
.buffered(4) .buffered(4)
.collect::<Vec<_>>() .collect()
.await; .await;
let mut seen: HashSet<u64> = HashSet::new(); let mut seen: HashSet<String> = HashSet::new();
let mut pool: Vec<TrackReco> = Vec::new(); let mut pool: Vec<AlbumReco> = Vec::new();
for tracks in per_artist { for albums in per_artist {
// Skip the artist's top hits to favor deep cuts. for album in albums.into_iter().skip(2) {
for track in tracks.into_iter().skip(DEEP_CUT_SKIP_TOP) { if album.id.is_empty()
if !track.streamable { || inputs.local.played_album_ids.contains(&album.id)
|| !seen.insert(album.id.clone())
{
continue; continue;
} }
let artist = track let subtitle = format!("Deep cut · {}", album.artist.name);
.performer pool.push(build_album_reco(&album, subtitle, RecoSource::Internal));
.as_ref()
.map(|a| a.name.clone())
.unwrap_or_default();
let k = track_key(&artist, &track.title);
// Not scrobbled externally AND not played in-app.
if history.track_keys.contains(&k) || inputs.local.played_track_ids.contains(&track.id) {
continue;
}
if !seen.insert(track.id) {
continue;
}
pool.push(TrackReco {
qobuz_track_id: track.id,
title: track.title.clone(),
artist,
artwork_url: track
.album
.as_ref()
.and_then(|al| al.image.best().cloned())
.unwrap_or_default(),
source: RecoSource::Internal,
});
} }
} }
rotate_take(pool, inputs.rotation_seed, DISPLAY_CAP) rotate_take(pool, inputs.rotation_seed, DISPLAY_CAP)
} }
// ── Cold-start fallback — Qobuz editorial top (albums + artists) ──────────── // ── Cold-start editorial (top albums + artists) ─────────────────────────────
async fn build_editorial_fallback(inputs: &RecoInputs<'_>) -> ExternalCarousels {
pub async fn build_editorial(inputs: &RecoInputs<'_>) -> (Vec<AlbumReco>, Vec<ArtistReco>) {
let (most_streamed, new_releases) = tokio::join!( let (most_streamed, new_releases) = tokio::join!(
inputs.catalog.featured_albums("most-streamed", 20), inputs.catalog.featured_albums("most-streamed", 20),
inputs.catalog.featured_albums("new-releases", 20), inputs.catalog.featured_albums("new-releases", 20),
@@ -497,12 +387,11 @@ async fn build_editorial_fallback(inputs: &RecoInputs<'_>) -> ExternalCarousels
let mut top_albums: Vec<AlbumReco> = Vec::new(); let mut top_albums: Vec<AlbumReco> = Vec::new();
for album in most_streamed.iter().chain(new_releases.iter()) { for album in most_streamed.iter().chain(new_releases.iter()) {
if !album.id.is_empty() && seen_albums.insert(album.id.clone()) { if !album.id.is_empty() && seen_albums.insert(album.id.clone()) {
top_albums.push(map_album_reco(album)); top_albums.push(build_album_reco(album, String::new(), RecoSource::Editorial));
} }
} }
top_albums.truncate(20); top_albums.truncate(20);
// Top artists: distinct album artists, portraits fetched by id.
let mut seen_artists: HashSet<u64> = HashSet::new(); let mut seen_artists: HashSet<u64> = HashSet::new();
let mut artist_ids: Vec<(u64, String)> = Vec::new(); let mut artist_ids: Vec<(u64, String)> = Vec::new();
for album in most_streamed.iter().chain(new_releases.iter()) { for album in most_streamed.iter().chain(new_releases.iter()) {
@@ -526,6 +415,7 @@ async fn build_editorial_fallback(inputs: &RecoInputs<'_>) -> ExternalCarousels
qobuz_artist_id: id, qobuz_artist_id: id,
name, name,
image_url, image_url,
subtitle: String::new(),
source: RecoSource::Editorial, source: RecoSource::Editorial,
} }
} }
@@ -534,13 +424,5 @@ async fn build_editorial_fallback(inputs: &RecoInputs<'_>) -> ExternalCarousels
.collect() .collect()
.await; .await;
ExternalCarousels { (top_albums, top_artists)
editorial_fallback: true,
similar_artists: Vec::new(),
similar_tracks: Vec::new(),
rediscover_tracks: Vec::new(),
deep_cut_tracks: Vec::new(),
top_albums,
top_artists,
}
} }
+90 -34
View File
@@ -1,23 +1,18 @@
//! Frontend-agnostic external-recommendations engine for Discover (ADR-006). //! Frontend-agnostic external-recommendations engine for Discover (ADR-006).
//! //!
//! Blends up to three sources — the in-house artist-vector engine (`qbz-reco`, //! Blends Last.fm + ListenBrainz into the Discover "Recommendations" tab,
//! deferred/placeholder), Last.fm, and ListenBrainz — into the Discover //! validating every candidate against the Qobuz catalog before display (the app
//! "External Recommendations" carousels, validating every candidate against the //! can only play Qobuz content). Lineup (owner-directed 2026-06-27):
//! Qobuz catalog before it is shown (the app can only play Qobuz content). //! - Recommended Artists (Last.fm similar of your recent top, not heard).
//! - Recommended Albums (Last.fm artist top-albums, not scrobbled).
//! - Fresh Releases (ListenBrainz, from artists you follow).
//! - Weekly Exploration / Weekly Jams (ListenBrainz curated playlists).
//! - Deep-cut albums from artists you know.
//! - Cold-start fallback: Qobuz editorial top albums + artists.
//! //!
//! Owner design (2026-06-26/27): //! The per-row builders are public so the frontend can paint each row the moment
//! - C1 "Similar artists you haven't heard" + C2 "Similar tracks you haven't //! it resolves (progressive load). The "heard" filters compare against the LOCAL
//! heard" = pure discovery, **blended** across all connected sources. //! reco-store history + a light Last.fm/LB top-set (not a full history sweep).
//! - C3 "Listened but not recently" = re-discovery (Last.fm long-term tops).
//! - C4 "From artists you know but not scrobbled" = deep cuts (Qobuz catalog
//! of known artists, minus the external scrobble set).
//! - Each carousel caps at 20 with daily rotation over a larger pool.
//! - NEVER hide: with no connected external source it falls back to Qobuz
//! editorial top albums + artists (already Qobuz-native, zero validation).
//!
//! The crate depends only on frontend-agnostic crates (`qbz-integrations`,
//! `qbz-models`). The Qobuz catalog is reached via the [`RecoCatalog`] trait,
//! which the frontend implements over its own core.
pub mod cache; pub mod cache;
pub mod matching; pub mod matching;
@@ -33,56 +28,117 @@ use qbz_models::{Album, Artist, Track};
pub use cache::RecoCache; pub use cache::RecoCache;
pub use types::{ pub use types::{
AlbumReco, ArtistReco, ExternalCarousels, LocalHistory, RecoSource, TrackReco, AlbumReco, ArtistReco, ExternalCarousels, ExtHistory, LocalHistory, RecoSource, TrackReco,
}; };
/// The Qobuz catalog operations the engine needs. Implemented by the frontend /// The Qobuz catalog operations the engine needs. Implemented by the frontend
/// over its own `QbzCore`. Every method swallows errors to an empty result — /// over its own `QbzCore`. Every method swallows errors to an empty result.
/// "no data" must never be an error to the engine.
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait RecoCatalog: Send + Sync { pub trait RecoCatalog: Send + Sync {
/// Free-text Qobuz track search (also used for ISRC-as-query).
async fn search_tracks(&self, query: &str, limit: usize) -> Vec<Track>; async fn search_tracks(&self, query: &str, limit: usize) -> Vec<Track>;
/// Qobuz artist search (for validating a recommended artist name).
async fn search_artists(&self, query: &str, limit: usize) -> Vec<Artist>; async fn search_artists(&self, query: &str, limit: usize) -> Vec<Artist>;
/// An artist's top tracks (the deep-cut candidate source for C4). /// Free-text Qobuz album search (for validating a recommended album).
async fn search_albums(&self, query: &str, limit: usize) -> Vec<Album>;
async fn artist_top_tracks(&self, artist_id: u64, limit: usize) -> Vec<Track>; async fn artist_top_tracks(&self, artist_id: u64, limit: usize) -> Vec<Track>;
/// An artist's albums (the deep-cut candidate source).
async fn artist_albums(&self, artist_id: u64, limit: usize) -> Vec<Album>;
/// Editorial featured albums by kind ("most-streamed" | "new-releases" | …). /// Editorial featured albums by kind ("most-streamed" | "new-releases" | …).
async fn featured_albums(&self, kind: &str, limit: usize) -> Vec<Album>; async fn featured_albums(&self, kind: &str, limit: usize) -> Vec<Album>;
/// Full artist by id (cold-start top-artist portraits).
async fn get_artist(&self, artist_id: u64) -> Option<Artist>; async fn get_artist(&self, artist_id: u64) -> Option<Artist>;
} }
/// A connected Last.fm account: the public username + a ready client.
pub struct LastFmHandle<'a> { pub struct LastFmHandle<'a> {
pub username: String, pub username: String,
pub client: &'a LastFmClient, pub client: &'a LastFmClient,
} }
/// A connected ListenBrainz account: the public username + a ready client.
pub struct ListenBrainzHandle<'a> { pub struct ListenBrainzHandle<'a> {
pub username: String, pub username: String,
pub client: &'a ListenBrainzClient, pub client: &'a ListenBrainzClient,
} }
/// All the inputs `build_external_carousels` needs. Borrowed so the frontend
/// owns the clients/cache and the engine stays allocation-light.
pub struct RecoInputs<'a> { pub struct RecoInputs<'a> {
pub lastfm: Option<LastFmHandle<'a>>, pub lastfm: Option<LastFmHandle<'a>>,
pub listenbrainz: Option<ListenBrainzHandle<'a>>, pub listenbrainz: Option<ListenBrainzHandle<'a>>,
pub musicbrainz: &'a MusicBrainzClient, pub musicbrainz: &'a MusicBrainzClient,
pub catalog: &'a dyn RecoCatalog, pub catalog: &'a dyn RecoCatalog,
/// Optional resolution cache (per-user SQLite). `None` disables caching.
pub cache: Option<&'a Mutex<RecoCache>>, pub cache: Option<&'a Mutex<RecoCache>>,
/// Local listening signal from the `reco_events` store.
pub local: LocalHistory, pub local: LocalHistory,
/// Daily rotation offset (e.g. days since the Unix epoch). /// Daily rotation offset (e.g. days since the Unix epoch).
pub rotation_seed: u64, pub rotation_seed: u64,
} }
/// Build the Discover "External Recommendations" carousels. Cheap to call impl RecoInputs<'_> {
/// repeatedly thanks to the resolution cache; the frontend should still throttle /// Whether any external source is connected.
/// rebuilds (e.g. once per session) to bound external-API load. pub fn has_external(&self) -> bool {
self.lastfm.is_some() || self.listenbrainz.is_some()
}
}
/// True when no external source is connected -> editorial fallback regime.
pub fn is_cold_start(inputs: &RecoInputs<'_>) -> bool {
!inputs.has_external()
}
/// Gather the external "heard" history ONCE (shared across all row builders).
pub async fn gather_history(inputs: &RecoInputs<'_>) -> ExtHistory {
carousels::gather_history(inputs).await
}
// ── Per-row builders (progressive: the frontend paints each as it resolves) ──
pub async fn build_rec_artists(inputs: &RecoInputs<'_>, history: &ExtHistory) -> Vec<ArtistReco> {
carousels::build_rec_artists(inputs, history).await
}
pub async fn build_rec_albums(inputs: &RecoInputs<'_>, history: &ExtHistory) -> Vec<AlbumReco> {
carousels::build_rec_albums(inputs, history).await
}
pub async fn build_fresh_releases(inputs: &RecoInputs<'_>) -> Vec<AlbumReco> {
carousels::build_fresh_releases(inputs).await
}
pub async fn build_weekly_exploration(inputs: &RecoInputs<'_>) -> Vec<TrackReco> {
carousels::build_weekly(inputs, "weekly-exploration").await
}
pub async fn build_weekly_jams(inputs: &RecoInputs<'_>) -> Vec<TrackReco> {
carousels::build_weekly(inputs, "weekly-jams").await
}
pub async fn build_deep_cut_albums(inputs: &RecoInputs<'_>) -> Vec<AlbumReco> {
carousels::build_deep_cut_albums(inputs).await
}
/// Cold-start editorial (top albums + artists).
pub async fn build_editorial(inputs: &RecoInputs<'_>) -> (Vec<AlbumReco>, Vec<ArtistReco>) {
carousels::build_editorial(inputs).await
}
/// Convenience: build the whole set at once (non-progressive callers / tests).
pub async fn build_external_carousels(inputs: RecoInputs<'_>) -> ExternalCarousels { pub async fn build_external_carousels(inputs: RecoInputs<'_>) -> ExternalCarousels {
carousels::build(inputs).await if is_cold_start(&inputs) {
let (top_albums, top_artists) = build_editorial(&inputs).await;
return ExternalCarousels {
editorial_fallback: true,
top_albums,
top_artists,
..Default::default()
};
}
let history = gather_history(&inputs).await;
let (rec_artists, rec_albums, fresh_releases, weekly_exploration, weekly_jams, deep_cut_albums) = tokio::join!(
build_rec_artists(&inputs, &history),
build_rec_albums(&inputs, &history),
build_fresh_releases(&inputs),
build_weekly_exploration(&inputs),
build_weekly_jams(&inputs),
build_deep_cut_albums(&inputs),
);
ExternalCarousels {
editorial_fallback: false,
rec_artists,
rec_albums,
fresh_releases,
weekly_exploration,
weekly_jams,
deep_cut_albums,
top_albums: Vec::new(),
top_artists: Vec::new(),
}
} }
+76 -35
View File
@@ -1,5 +1,7 @@
//! Data types for the external-recommendations engine. //! Data types for the external-recommendations engine.
use std::collections::HashSet;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// Which source produced a recommendation row item. /// Which source produced a recommendation row item.
@@ -13,16 +15,28 @@ pub enum RecoSource {
Editorial, Editorial,
} }
/// A raw artist candidate before Qobuz validation. // ── Raw candidates (pre Qobuz validation) ──────────────────────────────────
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ArtistCandidate { pub struct ArtistCandidate {
pub name: String, pub name: String,
pub source: RecoSource, pub source: RecoSource,
/// Source-normalized similarity score in 0..1.
pub score: f32, pub score: f32,
/// "Similar to X, Y, Z" line, built from the seeds that surfaced this artist.
pub subtitle: String,
}
#[derive(Debug, Clone)]
pub struct AlbumCandidate {
pub artist: String,
pub title: String,
pub upc: Option<String>,
pub source: RecoSource,
pub score: f32,
/// "Similar to …" / "You've scrobbled {artist} before" line.
pub subtitle: String,
} }
/// A raw track candidate before Qobuz validation.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TrackCandidate { pub struct TrackCandidate {
pub artist: String, pub artist: String,
@@ -32,19 +46,44 @@ pub struct TrackCandidate {
pub isrc: Option<String>, pub isrc: Option<String>,
pub recording_mbid: Option<String>, pub recording_mbid: Option<String>,
pub source: RecoSource, pub source: RecoSource,
/// Source-normalized similarity score in 0..1.
pub score: f32, pub score: f32,
} }
// ── Resolved rows (validated to Qobuz) ─────────────────────────────────────
/// A resolved artist row (validated to a Qobuz artist). /// A resolved artist row (validated to a Qobuz artist).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtistReco { pub struct ArtistReco {
pub qobuz_artist_id: u64, pub qobuz_artist_id: u64,
pub name: String, pub name: String,
pub image_url: String, pub image_url: String,
/// "Similar to X, Y, Z".
#[serde(default)]
pub subtitle: String,
pub source: RecoSource, pub source: RecoSource,
} }
/// A resolved album row (validated to a Qobuz album).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AlbumReco {
pub qobuz_album_id: String,
pub title: String,
pub artist: String,
pub artist_id: String,
pub year: String,
pub quality_tier: String,
pub quality_label: String,
pub artwork_url: String,
#[serde(default)]
pub subtitle: String,
#[serde(default = "default_source")]
pub source: RecoSource,
}
fn default_source() -> RecoSource {
RecoSource::Editorial
}
/// A resolved track row (validated to / sourced from a Qobuz track). /// A resolved track row (validated to / sourced from a Qobuz track).
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrackReco { pub struct TrackReco {
@@ -55,49 +94,51 @@ pub struct TrackReco {
pub source: RecoSource, pub source: RecoSource,
} }
/// One album row for the cold-start editorial fallback.
#[derive(Debug, Clone)]
pub struct AlbumReco {
pub qobuz_album_id: String,
pub title: String,
pub artist: String,
pub artist_id: String,
pub year: String,
pub quality_tier: String,
pub quality_label: String,
pub artwork_url: String,
}
/// The full external-recommendations result for the Discover section. /// The full external-recommendations result for the Discover section.
/// ///
/// At runtime exactly one regime is populated: `editorial_fallback == true` /// Empty vecs self-hide their row in the view, so partial population is always
/// fills `top_albums`/`top_artists` (cold start, no connected external source), /// safe — the controller paints each row independently as it resolves.
/// otherwise the four personalized carousels are filled. Empty vecs self-hide
/// their row in the view, so partial population is always safe.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct ExternalCarousels { pub struct ExternalCarousels {
/// No external integration connected -> editorial fallback regime. /// No connected external source -> editorial fallback regime.
pub editorial_fallback: bool, pub editorial_fallback: bool,
/// C1 — "Similar artists you haven't heard". /// Recommended artists (Last.fm similar, not heard).
pub similar_artists: Vec<ArtistReco>, pub rec_artists: Vec<ArtistReco>,
/// C2 — "Similar tracks you haven't heard". /// Recommended albums (Last.fm artist top-albums, not scrobbled).
pub similar_tracks: Vec<TrackReco>, pub rec_albums: Vec<AlbumReco>,
/// C3 — "Listened but not recently". /// Fresh releases (ListenBrainz, from artists you follow).
pub rediscover_tracks: Vec<TrackReco>, pub fresh_releases: Vec<AlbumReco>,
/// C4 — "From artists you know but not scrobbled" (deep cuts). /// Weekly Exploration (ListenBrainz discovery playlist) tracks.
pub deep_cut_tracks: Vec<TrackReco>, pub weekly_exploration: Vec<TrackReco>,
/// Weekly Jams (ListenBrainz familiar playlist) tracks.
pub weekly_jams: Vec<TrackReco>,
/// Deep-cut albums from artists you know.
pub deep_cut_albums: Vec<AlbumReco>,
/// Cold-start fallback: Qobuz editorial top albums. /// Cold-start fallback: Qobuz editorial top albums.
pub top_albums: Vec<AlbumReco>, pub top_albums: Vec<AlbumReco>,
/// Cold-start fallback: Qobuz editorial top artists. /// Cold-start fallback: Qobuz editorial top artists.
pub top_artists: Vec<ArtistReco>, pub top_artists: Vec<ArtistReco>,
} }
/// Local listening signal derived from the per-user `reco_events` store (all ids /// Local listening signal from the per-user `reco_events` store (Qobuz ids).
/// are Qobuz ids; the play log is Qobuz-source-gated).
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct LocalHistory { pub struct LocalHistory {
/// Artists the user already knows (played > threshold or favorited). /// Artists the user already knows (played > threshold or favorited).
pub known_artist_ids: std::collections::HashSet<u64>, pub known_artist_ids: HashSet<u64>,
/// Tracks played in-app (the local "already heard" set). /// Tracks played in-app.
pub played_track_ids: std::collections::HashSet<u64>, pub played_track_ids: HashSet<u64>,
/// Albums played in-app (the local "already heard albums" set).
pub played_album_ids: HashSet<String>,
}
/// External listening signal (normalized) for the "not heard / not scrobbled"
/// filters. Gathered ONCE per build and shared across the per-row builders.
#[derive(Debug, Clone, Default)]
pub struct ExtHistory {
/// Normalized artist names the user has listened to (Last.fm + LB).
pub artist_names: HashSet<String>,
/// Normalized "artist|title" track keys (scrobbled set).
pub track_keys: HashSet<String>,
/// Normalized "artist|album" keys (scrobbled-album set).
pub album_keys: HashSet<String>,
} }
+125 -32
View File
@@ -1,28 +1,28 @@
//! Resolve a recommendation candidate to a real, streamable Qobuz entity. //! Resolve a recommendation candidate to a real Qobuz entity (track/artist/album).
//! //!
//! Precedence for tracks (strongest key first): //! Tracks: ISRC -> MusicBrainz `inc=isrcs` -> Qobuz, else fuzzy text.
//! 1. ISRC known -> Qobuz text-search the ISRC, accept only on an //! Artists: Qobuz artist-search + normalized-name match.
//! exact `Track.isrc` match (Qobuz indexes ISRC). //! Albums: UPC match if known, else fuzzy text (title*0.6 + artist*0.4).
//! 2. recording_mbid known -> MusicBrainz `inc=isrcs` bridge -> step 1. //! Every outcome (positive AND negative) is cached.
//! 3. names only -> fuzzy text search scored by [`crate::matching`],
//! gated at `MIN_SCORE`.
//! Artists resolve by Qobuz artist-search + normalized-name match.
//!
//! Every outcome (positive AND negative) is cached so rotation re-renders and
//! repeat builds never re-hit the Qobuz/MusicBrainz APIs for a known candidate.
use std::sync::Mutex; use std::sync::Mutex;
use qbz_integrations::MusicBrainzClient; use qbz_integrations::MusicBrainzClient;
use qbz_models::Track; use qbz_models::{Album, Track};
use crate::cache::{CacheLookup, RecoCache}; use crate::cache::{CacheLookup, RecoCache};
use crate::matching::{normalize, select_best_match, MatchInput, MIN_SCORE}; use crate::matching::{normalize, select_best_match, similarity, MatchInput, MIN_SCORE};
use crate::types::{ArtistCandidate, ArtistReco, TrackCandidate, TrackReco}; use crate::types::{
AlbumCandidate, AlbumReco, ArtistCandidate, ArtistReco, RecoSource, TrackCandidate, TrackReco,
};
use crate::RecoCatalog; use crate::RecoCatalog;
type Cache<'a> = Option<&'a Mutex<RecoCache>>; type Cache<'a> = Option<&'a Mutex<RecoCache>>;
const ALBUM_MIN_SCORE: f32 = 0.6;
// ── Tracks ─────────────────────────────────────────────────────────────────
fn track_cache_key(c: &TrackCandidate) -> String { fn track_cache_key(c: &TrackCandidate) -> String {
if let Some(isrc) = c.isrc.as_deref().filter(|s| !s.is_empty()) { if let Some(isrc) = c.isrc.as_deref().filter(|s| !s.is_empty()) {
format!("t:isrc:{}", isrc.to_uppercase()) format!("t:isrc:{}", isrc.to_uppercase())
@@ -33,7 +33,7 @@ fn track_cache_key(c: &TrackCandidate) -> String {
} }
} }
fn build_track_reco(track: &Track, source: crate::types::RecoSource) -> TrackReco { fn build_track_reco(track: &Track, source: RecoSource) -> TrackReco {
TrackReco { TrackReco {
qobuz_track_id: track.id, qobuz_track_id: track.id,
title: track.title.clone(), title: track.title.clone(),
@@ -51,8 +51,6 @@ fn build_track_reco(track: &Track, source: crate::types::RecoSource) -> TrackRec
} }
} }
/// Find a Qobuz track whose ISRC matches `isrc` exactly (Qobuz has no ISRC
/// endpoint; it indexes ISRC in free-text search, so we verify the field).
async fn find_by_isrc(catalog: &dyn RecoCatalog, isrc: &str) -> Option<Track> { async fn find_by_isrc(catalog: &dyn RecoCatalog, isrc: &str) -> Option<Track> {
let results = catalog.search_tracks(isrc, 5).await; let results = catalog.search_tracks(isrc, 5).await;
results.into_iter().find(|t| { results.into_iter().find(|t| {
@@ -69,13 +67,11 @@ async fn resolve_track_live(
mb: &MusicBrainzClient, mb: &MusicBrainzClient,
cand: &TrackCandidate, cand: &TrackCandidate,
) -> Option<TrackReco> { ) -> Option<TrackReco> {
// 1. ISRC direct.
if let Some(isrc) = cand.isrc.as_deref().filter(|s| !s.is_empty()) { if let Some(isrc) = cand.isrc.as_deref().filter(|s| !s.is_empty()) {
if let Some(track) = find_by_isrc(catalog, isrc).await { if let Some(track) = find_by_isrc(catalog, isrc).await {
return Some(build_track_reco(&track, cand.source)); return Some(build_track_reco(&track, cand.source));
} }
} }
// 2. recording_mbid -> MusicBrainz ISRCs -> ISRC.
if let Some(mbid) = cand.recording_mbid.as_deref().filter(|s| !s.is_empty()) { if let Some(mbid) = cand.recording_mbid.as_deref().filter(|s| !s.is_empty()) {
let isrcs = mb.get_recording_isrcs(mbid).await.unwrap_or_default(); let isrcs = mb.get_recording_isrcs(mbid).await.unwrap_or_default();
for isrc in isrcs { for isrc in isrcs {
@@ -84,7 +80,6 @@ async fn resolve_track_live(
} }
} }
} }
// 3. Fuzzy text search.
let query = format!("{} {}", cand.artist, cand.title); let query = format!("{} {}", cand.artist, cand.title);
let candidates = catalog.search_tracks(query.trim(), 20).await; let candidates = catalog.search_tracks(query.trim(), 20).await;
let input = MatchInput { let input = MatchInput {
@@ -101,7 +96,6 @@ async fn resolve_track_live(
} }
} }
/// Validate a track candidate, using the cache for both hits and misses.
pub async fn validate_track( pub async fn validate_track(
catalog: &dyn RecoCatalog, catalog: &dyn RecoCatalog,
mb: &MusicBrainzClient, mb: &MusicBrainzClient,
@@ -123,16 +117,11 @@ pub async fn validate_track(
} }
} }
} }
let reco = resolve_track_live(catalog, mb, cand).await; let reco = resolve_track_live(catalog, mb, cand).await;
if let Some(c) = cache { if let Some(c) = cache {
if let Ok(guard) = c.lock() { if let Ok(guard) = c.lock() {
match &reco { match &reco {
Some(r) => { Some(r) => guard.put(&key, "track", Some(&serde_json::to_string(r).unwrap_or_default())),
let json = serde_json::to_string(r).unwrap_or_default();
guard.put(&key, "track", Some(&json));
}
None => guard.put(&key, "track", None), None => guard.put(&key, "track", None),
} }
} }
@@ -140,7 +129,8 @@ pub async fn validate_track(
reco reco
} }
/// Validate an artist candidate (name -> Qobuz artist), cached. // ── Artists ────────────────────────────────────────────────────────────────
pub async fn validate_artist( pub async fn validate_artist(
catalog: &dyn RecoCatalog, catalog: &dyn RecoCatalog,
cache: Cache<'_>, cache: Cache<'_>,
@@ -153,6 +143,7 @@ pub async fn validate_artist(
CacheLookup::Found(json) => { CacheLookup::Found(json) => {
if let Ok(mut reco) = serde_json::from_str::<ArtistReco>(&json) { if let Ok(mut reco) = serde_json::from_str::<ArtistReco>(&json) {
reco.source = cand.source; reco.source = cand.source;
reco.subtitle = cand.subtitle.clone();
return Some(reco); return Some(reco);
} }
} }
@@ -173,19 +164,121 @@ pub async fn validate_artist(
qobuz_artist_id: a.id, qobuz_artist_id: a.id,
name: a.name.clone(), name: a.name.clone(),
image_url: a.image.as_ref().and_then(|i| i.best().cloned()).unwrap_or_default(), image_url: a.image.as_ref().and_then(|i| i.best().cloned()).unwrap_or_default(),
subtitle: cand.subtitle.clone(),
source: cand.source, source: cand.source,
}); });
if let Some(c) = cache { if let Some(c) = cache {
if let Ok(guard) = c.lock() { if let Ok(guard) = c.lock() {
match &reco { match &reco {
Some(r) => { Some(r) => guard.put(&key, "artist", Some(&serde_json::to_string(r).unwrap_or_default())),
let json = serde_json::to_string(r).unwrap_or_default();
guard.put(&key, "artist", Some(&json));
}
None => guard.put(&key, "artist", None), None => guard.put(&key, "artist", None),
} }
} }
} }
reco reco
} }
// ── Albums ─────────────────────────────────────────────────────────────────
fn album_cache_key(c: &AlbumCandidate) -> String {
if let Some(upc) = c.upc.as_deref().filter(|s| !s.is_empty()) {
format!("alb:upc:{}", upc)
} else {
format!("alb:{}|{}", normalize(&c.artist), normalize(&c.title))
}
}
pub fn build_album_reco(album: &Album, subtitle: String, source: RecoSource) -> AlbumReco {
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(),
};
AlbumReco {
qobuz_album_id: album.id.clone(),
title: album.title.clone(),
artist: album.artist.name.clone(),
artist_id: album.artist.id.to_string(),
year,
quality_tier,
quality_label,
artwork_url: album.image.best().cloned().unwrap_or_default(),
subtitle,
source,
}
}
async fn resolve_album_live(catalog: &dyn RecoCatalog, cand: &AlbumCandidate) -> Option<AlbumReco> {
if let Some(upc) = cand.upc.as_deref().filter(|s| !s.is_empty()) {
let albums = catalog.search_albums(upc, 5).await;
if let Some(a) = albums
.iter()
.find(|a| a.upc.as_deref().map(|u| u.eq_ignore_ascii_case(upc)).unwrap_or(false))
{
return Some(build_album_reco(a, cand.subtitle.clone(), cand.source));
}
}
let query = format!("{} {}", cand.artist, cand.title);
let albums = catalog.search_albums(query.trim(), 10).await;
let mut best: Option<&Album> = None;
let mut best_score = 0.0f32;
for a in &albums {
let title_s = similarity(&cand.title, &a.title);
let artist_s = similarity(&cand.artist, &a.artist.name);
let score = title_s * 0.6 + artist_s * 0.4;
if score > best_score {
best_score = score;
best = Some(a);
}
}
match best {
Some(a) if best_score >= ALBUM_MIN_SCORE => {
Some(build_album_reco(a, cand.subtitle.clone(), cand.source))
}
_ => None,
}
}
pub async fn validate_album(
catalog: &dyn RecoCatalog,
cache: Cache<'_>,
cand: &AlbumCandidate,
) -> Option<AlbumReco> {
let key = album_cache_key(cand);
if let Some(c) = cache {
if let Ok(guard) = c.lock() {
match guard.get(&key) {
CacheLookup::Found(json) => {
if let Ok(mut reco) = serde_json::from_str::<AlbumReco>(&json) {
reco.source = cand.source;
reco.subtitle = cand.subtitle.clone();
return Some(reco);
}
}
CacheLookup::Negative => return None,
CacheLookup::Miss => {}
}
}
}
let reco = resolve_album_live(catalog, cand).await;
if let Some(c) = cache {
if let Ok(guard) = c.lock() {
match &reco {
Some(r) => guard.put(&key, "album", Some(&serde_json::to_string(r).unwrap_or_default())),
None => guard.put(&key, "album", None),
}
}
}
reco
}
+165 -2
View File
@@ -4,8 +4,8 @@ use reqwest::Client;
use serde_json::json; use serde_json::json;
use super::models::{ use super::models::{
AuthGetSessionResponse, AuthGetTokenResponse, LastFmArtist, LastFmResponse, LastFmSession, AuthGetSessionResponse, AuthGetTokenResponse, LastFmAlbum, LastFmArtist, LastFmResponse,
LastFmSimilarArtist, LastFmSimilarTrack, LastFmTrack, LastFmSession, LastFmSimilarArtist, LastFmSimilarTrack, LastFmTrack,
}; };
use crate::error::{IntegrationError, IntegrationResult}; use crate::error::{IntegrationError, IntegrationResult};
@@ -713,6 +713,169 @@ impl LastFmClient {
Ok(tracks) Ok(tracks)
} }
/// artist.getTopAlbums — an artist's most-popular albums (global playcount).
///
/// Source for "Recommended Albums" candidates. Public read endpoint — no
/// session key needed (the proxy injects the API key).
pub async fn get_artist_top_albums(
&self,
artist: &str,
limit: u32,
) -> IntegrationResult<Vec<LastFmAlbum>> {
let url = format!("{}/artist.getTopAlbums", LASTFM_PROXY_URL);
let response = self
.client
.post(&url)
.json(&json!({
"artist": artist,
"limit": limit,
}))
.send()
.await?;
if !response.status().is_success() {
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"Last.fm artist.getTopAlbums failed: {}",
text
)));
}
let text = response.text().await?;
let data: serde_json::Value = serde_json::from_str(&text)?;
if let Some(error) = data.get("error") {
let message = data
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
return Err(IntegrationError::api(
error.as_u64().unwrap_or(0) as u32,
message.to_string(),
));
}
let albums = data
.get("topalbums")
.and_then(|ta| ta.get("album"))
.and_then(|a| a.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
let name = item.get("name")?.as_str()?.to_string();
let mbid = extract_mbid(item);
let artist_obj = item.get("artist");
let artist = artist_obj
.and_then(|a| a.get("name"))
.and_then(|n| n.as_str())
.unwrap_or_default()
.to_string();
let artist_mbid = artist_obj.and_then(|a| extract_mbid(a));
let image = extract_image(item);
let playcount = parse_u64(item.get("playcount"));
Some(LastFmAlbum {
name,
artist,
artist_mbid,
mbid,
image,
playcount,
})
})
.collect()
})
.unwrap_or_default();
Ok(albums)
}
/// user.getTopAlbums — the USER's scrobbled albums (their playcount).
///
/// The "already heard" exclusion set for Recommended Albums. Public read
/// endpoint — no session key needed (the proxy injects the API key).
/// `period` must be one of: `overall|7day|1month|3month|6month|12month`.
pub async fn get_user_top_albums(
&self,
user: &str,
period: &str,
limit: u32,
page: u32,
) -> IntegrationResult<Vec<LastFmAlbum>> {
let url = format!("{}/user.getTopAlbums", LASTFM_PROXY_URL);
let response = self
.client
.post(&url)
.json(&json!({
"user": user,
"period": period,
"limit": limit,
"page": page,
}))
.send()
.await?;
if !response.status().is_success() {
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"Last.fm user.getTopAlbums failed: {}",
text
)));
}
let text = response.text().await?;
let data: serde_json::Value = serde_json::from_str(&text)?;
if let Some(error) = data.get("error") {
let message = data
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("Unknown error");
return Err(IntegrationError::api(
error.as_u64().unwrap_or(0) as u32,
message.to_string(),
));
}
let albums = data
.get("topalbums")
.and_then(|ta| ta.get("album"))
.and_then(|a| a.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| {
let name = item.get("name")?.as_str()?.to_string();
let mbid = extract_mbid(item);
let artist_obj = item.get("artist");
let artist = artist_obj
.and_then(|a| a.get("name"))
.and_then(|n| n.as_str())
.unwrap_or_default()
.to_string();
let artist_mbid = artist_obj.and_then(|a| extract_mbid(a));
let image = extract_image(item);
let playcount = parse_u64(item.get("playcount"));
Some(LastFmAlbum {
name,
artist,
artist_mbid,
mbid,
image,
playcount,
})
})
.collect()
})
.unwrap_or_default();
Ok(albums)
}
} }
/// Extract the largest image URL (last array entry's `#text`) from a Last.fm object. /// Extract the largest image URL (last array entry's `#text`) from a Last.fm object.
+1 -1
View File
@@ -32,5 +32,5 @@ mod models;
pub use client::LastFmClient; pub use client::LastFmClient;
pub use models::{ pub use models::{
LastFmArtist, LastFmSession, LastFmSimilarArtist, LastFmSimilarTrack, LastFmTrack, LastFmAlbum, LastFmArtist, LastFmSession, LastFmSimilarArtist, LastFmSimilarTrack, LastFmTrack,
}; };
@@ -74,6 +74,23 @@ pub struct LastFmArtist {
pub image: Option<String>, pub image: Option<String>,
} }
/// An album from Last.fm's artist.getTopAlbums / user.getTopAlbums
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LastFmAlbum {
/// Album name
pub name: String,
/// Artist name
pub artist: String,
/// Artist MusicBrainz ID if available
pub artist_mbid: Option<String>,
/// Album MusicBrainz ID if available
pub mbid: Option<String>,
/// Largest available image URL, if any
pub image: Option<String>,
/// Playcount (global for artist.getTopAlbums, the user's for user.getTopAlbums)
pub playcount: u64,
}
/// A track from Last.fm user methods (top / loved / recent tracks) /// A track from Last.fm user methods (top / loved / recent tracks)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LastFmTrack { pub struct LastFmTrack {
@@ -602,4 +602,365 @@ impl ListenBrainzClient {
Ok(recordings) Ok(recordings)
} }
/// List the "Created for you" curated playlists (Weekly Jams = familiar,
/// Weekly Exploration = discovery, Top Discoveries, etc.).
///
/// `GET /user/{user_name}/playlists/createdfor?count={count}`
///
/// Public read; the `Authorization` header is sent only when a token is
/// configured. HTTP 204/404 and empty/malformed bodies are treated as
/// "no data" -> `Ok(vec![])`.
///
/// Response shape: `{ playlists: [ { playlist: {...} }, ... ] }`. For each
/// inner `playlist` object:
/// - `title`
/// - `date` -> `created_at`
/// - `annotation`
/// - `playlist_mbid` = LAST path segment of `identifier` (string OR array;
/// e.g. `"https://listenbrainz.org/playlist/{mbid}"`)
/// - `source_patch` = `extension["https://musicbrainz.org/doc/jspf#playlist"]`
/// `.additional_metadata.algorithm_metadata.source_patch`
pub async fn get_created_for_playlists(
&self,
user_name: &str,
count: u32,
) -> IntegrationResult<Vec<LbPlaylistMeta>> {
let token = self.config.lock().await.token.clone();
let url = format!(
"{}/user/{}/playlists/createdfor",
LISTENBRAINZ_API_URL, user_name
);
let mut request = self
.client
.get(&url)
.query(&[("count", count.to_string())]);
if let Some(token) = token {
request = request.header("Authorization", format!("Token {}", token));
}
let response = request.send().await?;
let status = response.status();
if status == reqwest::StatusCode::NO_CONTENT || status == reqwest::StatusCode::NOT_FOUND {
return Ok(vec![]);
}
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"ListenBrainz created-for playlists failed: {} - {}",
status, text
)));
}
let body = response.text().await.unwrap_or_default();
if body.trim().is_empty() {
return Ok(vec![]);
}
let json: serde_json::Value = match serde_json::from_str(&body) {
Ok(value) => value,
Err(_) => return Ok(vec![]),
};
let playlists = json
.get("playlists")
.and_then(|playlists| playlists.as_array())
.cloned()
.unwrap_or_default();
let mut parsed = Vec::with_capacity(playlists.len());
for wrapper in playlists {
// Each array entry wraps the real object under a `playlist` key.
let playlist = match wrapper.get("playlist") {
Some(playlist) => playlist,
None => continue,
};
let playlist_mbid = match playlist
.get("identifier")
.and_then(last_identifier_segment)
{
Some(mbid) => mbid,
// No usable playlist id -> useless downstream; skip it.
None => continue,
};
let title = playlist
.get("title")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let created_at = playlist
.get("date")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let annotation = playlist
.get("annotation")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let source_patch = playlist
.get("extension")
.and_then(|ext| ext.get("https://musicbrainz.org/doc/jspf#playlist"))
.and_then(|ext| ext.get("additional_metadata"))
.and_then(|meta| meta.get("algorithm_metadata"))
.and_then(|algo| algo.get("source_patch"))
.and_then(|value| value.as_str())
.map(|value| value.to_string());
parsed.push(LbPlaylistMeta {
playlist_mbid,
title,
source_patch,
annotation,
created_at,
});
}
Ok(parsed)
}
/// Fetch one playlist's tracks (JSPF).
///
/// `GET /playlist/{playlist_mbid}`
///
/// Public read; the `Authorization` header is sent only when a token is
/// configured. HTTP 204/404 and empty/malformed bodies are treated as
/// "no data" -> `Ok(vec![])`.
///
/// Response shape: `{ playlist: { track: [ ... ] } }`. For each track:
/// - `title`
/// - `creator` -> `artist_name`
/// - `album` -> `release_name`
/// - `recording_mbid` = LAST path segment of the track `identifier`
/// (string OR array)
/// - `caa_id` + `caa_release_mbid` from
/// `extension["https://musicbrainz.org/doc/jspf#track"].additional_metadata`
pub async fn get_playlist_tracks(
&self,
playlist_mbid: &str,
) -> IntegrationResult<Vec<LbPlaylistTrack>> {
let token = self.config.lock().await.token.clone();
let url = format!("{}/playlist/{}", LISTENBRAINZ_API_URL, playlist_mbid);
let mut request = self.client.get(&url);
if let Some(token) = token {
request = request.header("Authorization", format!("Token {}", token));
}
let response = request.send().await?;
let status = response.status();
if status == reqwest::StatusCode::NO_CONTENT || status == reqwest::StatusCode::NOT_FOUND {
return Ok(vec![]);
}
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"ListenBrainz playlist tracks failed: {} - {}",
status, text
)));
}
let body = response.text().await.unwrap_or_default();
if body.trim().is_empty() {
return Ok(vec![]);
}
let json: serde_json::Value = match serde_json::from_str(&body) {
Ok(value) => value,
Err(_) => return Ok(vec![]),
};
let tracks = json
.get("playlist")
.and_then(|playlist| playlist.get("track"))
.and_then(|track| track.as_array())
.cloned()
.unwrap_or_default();
let mut parsed = Vec::with_capacity(tracks.len());
for track in tracks {
let title = track
.get("title")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let artist_name = track
.get("creator")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let release_name = track
.get("album")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let recording_mbid = track.get("identifier").and_then(last_identifier_segment);
let additional = track
.get("extension")
.and_then(|ext| ext.get("https://musicbrainz.org/doc/jspf#track"))
.and_then(|ext| ext.get("additional_metadata"));
let caa_id = additional
.and_then(|meta| meta.get("caa_id"))
.and_then(|value| value.as_i64());
let caa_release_mbid = additional
.and_then(|meta| meta.get("caa_release_mbid"))
.and_then(|value| value.as_str())
.map(|value| value.to_string());
parsed.push(LbPlaylistTrack {
recording_mbid,
title,
artist_name,
release_name,
caa_id,
caa_release_mbid,
});
}
Ok(parsed)
}
/// Personalized fresh releases.
///
/// `GET /user/{user_name}/fresh_releases?days={days}`
///
/// `days` is clamped to `1..=90`. Public read; the `Authorization` header
/// is sent only when a token is configured. HTTP 204/404 and empty/malformed
/// bodies are treated as "no data" -> `Ok(vec![])`.
///
/// Response shape: `{ payload: { releases: [ ... ] } }`. For each release:
/// - `release_name`
/// - `artist_credit_name`
/// - `release_mbid`
/// - `release_group_mbid`
/// - `release_group_primary_type` -> `primary_type`
/// - `caa_id`
/// - `caa_release_mbid`
/// - `release_date`
/// - `listen_count`
pub async fn get_fresh_releases(
&self,
user_name: &str,
days: u32,
) -> IntegrationResult<Vec<LbFreshRelease>> {
let token = self.config.lock().await.token.clone();
let days = days.clamp(1, 90);
let url = format!("{}/user/{}/fresh_releases", LISTENBRAINZ_API_URL, user_name);
let mut request = self.client.get(&url).query(&[("days", days.to_string())]);
if let Some(token) = token {
request = request.header("Authorization", format!("Token {}", token));
}
let response = request.send().await?;
let status = response.status();
if status == reqwest::StatusCode::NO_CONTENT || status == reqwest::StatusCode::NOT_FOUND {
return Ok(vec![]);
}
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
return Err(IntegrationError::internal(format!(
"ListenBrainz fresh releases failed: {} - {}",
status, text
)));
}
let body = response.text().await.unwrap_or_default();
if body.trim().is_empty() {
return Ok(vec![]);
}
let json: serde_json::Value = match serde_json::from_str(&body) {
Ok(value) => value,
Err(_) => return Ok(vec![]),
};
let releases = json
.get("payload")
.and_then(|payload| payload.get("releases"))
.and_then(|releases| releases.as_array())
.cloned()
.unwrap_or_default();
let mut parsed = Vec::with_capacity(releases.len());
for release in releases {
let release_name = release
.get("release_name")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let artist_credit_name = release
.get("artist_credit_name")
.and_then(|value| value.as_str())
.unwrap_or_default()
.to_string();
let release_mbid = release
.get("release_mbid")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let release_group_mbid = release
.get("release_group_mbid")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let primary_type = release
.get("release_group_primary_type")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let caa_id = release.get("caa_id").and_then(|value| value.as_i64());
let caa_release_mbid = release
.get("caa_release_mbid")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let release_date = release
.get("release_date")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let listen_count = release.get("listen_count").and_then(|value| value.as_u64());
parsed.push(LbFreshRelease {
release_name,
artist_credit_name,
release_mbid,
release_group_mbid,
primary_type,
caa_id,
caa_release_mbid,
release_date,
listen_count,
});
}
Ok(parsed)
}
}
/// Extract the last `/`-delimited segment of a JSPF `identifier` value.
///
/// ListenBrainz returns the `identifier` either as a single string
/// (`"https://listenbrainz.org/playlist/{mbid}"`) or as an array of such
/// strings. Returns the last non-empty path segment of the first usable value,
/// or `None` when nothing parseable is present.
fn last_identifier_segment(identifier: &serde_json::Value) -> Option<String> {
fn last_segment(raw: &str) -> Option<String> {
raw.trim_end_matches('/')
.rsplit('/')
.next()
.map(str::trim)
.filter(|segment| !segment.is_empty())
.map(|segment| segment.to_string())
}
match identifier {
serde_json::Value::String(raw) => last_segment(raw),
serde_json::Value::Array(items) => items
.iter()
.filter_map(serde_json::Value::as_str)
.find_map(last_segment),
_ => None,
}
} }
@@ -45,6 +45,7 @@ pub mod cache;
pub use client::{ListenBrainzClient, ListenBrainzConfig}; pub use client::{ListenBrainzClient, ListenBrainzConfig};
pub use models::{ pub use models::{
AdditionalInfo, CfRecommendation, LbListen, LbRecordingMeta, Listen, ListenBrainzStatus, AdditionalInfo, CfRecommendation, LbFreshRelease, LbListen, LbPlaylistMeta, LbPlaylistTrack,
ListenType, QueuedListen, SubmitListensPayload, TrackMetadata, UserInfo, LbRecordingMeta, Listen, ListenBrainzStatus, ListenType, QueuedListen, SubmitListensPayload,
TrackMetadata, UserInfo,
}; };
@@ -203,3 +203,66 @@ pub struct LbRecordingMeta {
/// Cover Art Archive release MBID, if available /// Cover Art Archive release MBID, if available
pub caa_release_mbid: Option<String>, pub caa_release_mbid: Option<String>,
} }
/// A "Created for you" curated playlist's metadata
///
/// Returned by `GET /user/{user_name}/playlists/createdfor`. Describes one of
/// the auto-generated playlists (Weekly Jams, Weekly Exploration, Top
/// Discoveries, etc.) without its tracks.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LbPlaylistMeta {
/// MusicBrainz playlist ID (last path segment of the JSPF `identifier`)
pub playlist_mbid: String,
/// Playlist title
pub title: String,
/// Generation algorithm patch name (e.g. `weekly-jams`, `weekly-exploration`)
pub source_patch: Option<String>,
/// Playlist annotation / description, if available
pub annotation: Option<String>,
/// ISO-8601 creation timestamp, if available
pub created_at: Option<String>,
}
/// A single track inside a curated playlist (JSPF entry)
///
/// Returned by `GET /playlist/{playlist_mbid}`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LbPlaylistTrack {
/// MusicBrainz recording ID (last path segment of the JSPF `identifier`)
pub recording_mbid: Option<String>,
/// Track title
pub title: String,
/// Artist name (JSPF `creator`)
pub artist_name: String,
/// Release/album name (JSPF `album`), if available
pub release_name: Option<String>,
/// Cover Art Archive image ID, if available
pub caa_id: Option<i64>,
/// Cover Art Archive release MBID, if available
pub caa_release_mbid: Option<String>,
}
/// A personalized fresh release entry
///
/// Returned by `GET /user/{user_name}/fresh_releases`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LbFreshRelease {
/// Release/album name
pub release_name: String,
/// Artist credit name
pub artist_credit_name: String,
/// MusicBrainz release ID, if available
pub release_mbid: Option<String>,
/// MusicBrainz release-group ID, if available
pub release_group_mbid: Option<String>,
/// Release-group primary type (Album, Single, EP, ...), if available
pub primary_type: Option<String>,
/// Cover Art Archive image ID, if available
pub caa_id: Option<i64>,
/// Cover Art Archive release MBID, if available
pub caa_release_mbid: Option<String>,
/// Release date (ISO-8601), if available
pub release_date: Option<String>,
/// Number of listens recorded for this release, if available
pub listen_count: Option<u64>,
}
@@ -80,6 +80,17 @@ export component ArtistCard inherits Rectangle {
overflow: elide; overflow: elide;
} }
// Optional "Similar to X, Y" line (external-reco "Recommended Artists").
// Empty for every other usage (search / Top Artists / To Follow) -> hidden.
if root.artist.subtitle != "": Text {
text: root.artist.subtitle;
width: 128px;
color: Theme.text-muted;
font-size: 11px;
horizontal-alignment: center;
overflow: elide;
}
// Rectangular Follow / Following chip (ADR-008 — no pills). See // Rectangular Follow / Following chip (ADR-008 — no pills). See
// `follow-mode`: "auto" shows it only when not yet following, // `follow-mode`: "auto" shows it only when not yet following,
// "toggle" always shows it (and reflects the state), "none" hides it. // "toggle" always shows it (and reflects the state), "none" hides it.
@@ -1,11 +1,10 @@
// Discover > Recommendations — the 4th tab. // Discover > Recommendations — the 4th tab.
// //
// External-recommendations engine (Last.fm + ListenBrainz -> Qobuz) carousels, // External-recommendations engine (Last.fm + ListenBrainz -> Qobuz). Artist /
// plus the Qobuz-editorial cold-start fallback. Mirrors ForYouView: hardcoded // album / track carousels, each with a `length > 0` self-hide gate; the
// arms, each with its own `length > 0` self-hide gate, reusing the shared // controller paints each row the moment its builder resolves (progressive).
// Carousel / SlimCarousel / ArtistCarousel components. Data lives in // Data lives in ExternalRecoState (crate::external_reco). Reuses the shared
// ExternalRecoState (populated by crate::external_reco). At runtime exactly one // Carousel / ArtistCarousel / SlimCarousel components.
// regime is non-empty: personalized (C1-C4) OR editorial fallback.
import { Theme } from "../foundation/semantic-colors.slint"; import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint"; import { Typography } from "../foundation/typography.slint";
@@ -22,23 +21,24 @@ export component ExternalRecoView inherits VerticalLayout {
callback play-track(string); callback play-track(string);
property <bool> any-content: property <bool> any-content:
ExternalRecoState.similar-artists.length > 0 ExternalRecoState.rec-artists.length > 0
|| ExternalRecoState.similar-tracks.length > 0 || ExternalRecoState.rec-albums.albums.length > 0
|| ExternalRecoState.rediscover-tracks.length > 0 || ExternalRecoState.fresh-releases.albums.length > 0
|| ExternalRecoState.deep-cut-tracks.length > 0 || ExternalRecoState.weekly-exploration.length > 0
|| ExternalRecoState.weekly-jams.length > 0
|| ExternalRecoState.deep-cut-albums.albums.length > 0
|| ExternalRecoState.top-albums.albums.length > 0 || ExternalRecoState.top-albums.albums.length > 0
|| ExternalRecoState.top-artists.length > 0; || ExternalRecoState.top-artists.length > 0;
spacing: 40px; spacing: 40px;
alignment: start; alignment: start;
// Skeleton while the first load is in flight and nothing has painted yet.
if ExternalRecoState.loading && !root.any-content: HomeSkeleton { } if ExternalRecoState.loading && !root.any-content: HomeSkeleton { }
// C1 — Similar artists you haven't heard (artist carousel). // Recommended Artists (Last.fm similar, not heard) — with "Similar to…".
if ExternalRecoState.similar-artists.length > 0: ArtistCarousel { if ExternalRecoState.rec-artists.length > 0: ArtistCarousel {
title: @tr("Similar artists you haven't heard"); title: @tr("Recommended Artists");
artists: ExternalRecoState.similar-artists; artists: ExternalRecoState.rec-artists;
artist-clicked(id) => { artist-clicked(id) => {
root.open-artist(id); root.open-artist(id);
} }
@@ -47,34 +47,58 @@ export component ExternalRecoView inherits VerticalLayout {
} }
} }
// C2 — Similar tracks you haven't heard (track slim carousel). // Recommended Albums (Last.fm artist top-albums, not scrobbled).
if ExternalRecoState.similar-tracks.length > 0: SlimCarousel { if ExternalRecoState.rec-albums.albums.length > 0: Carousel {
title: @tr("Similar tracks you haven't heard"); section: ExternalRecoState.rec-albums;
items: ExternalRecoState.similar-tracks; album-clicked(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
// Fresh Releases (ListenBrainz, from artists you follow).
if ExternalRecoState.fresh-releases.albums.length > 0: Carousel {
section: ExternalRecoState.fresh-releases;
album-clicked(id) => {
root.open-album(id);
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
// Weekly Exploration (ListenBrainz discovery playlist).
if ExternalRecoState.weekly-exploration.length > 0: SlimCarousel {
title: @tr("Weekly Exploration");
items: ExternalRecoState.weekly-exploration;
item-clicked(id) => { item-clicked(id) => {
root.play-track(id); root.play-track(id);
} }
} }
// C3 — Listened but not recently (re-discovery track carousel). // Weekly Jams (ListenBrainz familiar playlist).
if ExternalRecoState.rediscover-tracks.length > 0: SlimCarousel { if ExternalRecoState.weekly-jams.length > 0: SlimCarousel {
title: @tr("Listened, but not lately"); title: @tr("Weekly Jams");
items: ExternalRecoState.rediscover-tracks; items: ExternalRecoState.weekly-jams;
item-clicked(id) => { item-clicked(id) => {
root.play-track(id); root.play-track(id);
} }
} }
// C4 — From artists you know but not scrobbled (deep cuts). // Deep cuts from artists you know (Qobuz catalog albums).
if ExternalRecoState.deep-cut-tracks.length > 0: SlimCarousel { if ExternalRecoState.deep-cut-albums.albums.length > 0: Carousel {
title: @tr("Deep cuts from artists you know"); section: ExternalRecoState.deep-cut-albums;
items: ExternalRecoState.deep-cut-tracks; album-clicked(id) => {
item-clicked(id) => { root.open-album(id);
root.play-track(id); }
media-action(kind, id, action) => {
root.media-action(kind, id, action);
} }
} }
// Cold-start fallback — Qobuz editorial top albums (album carousel). // Cold-start fallback — Qobuz editorial top albums.
if ExternalRecoState.top-albums.albums.length > 0: Carousel { if ExternalRecoState.top-albums.albums.length > 0: Carousel {
section: ExternalRecoState.top-albums; section: ExternalRecoState.top-albums;
album-clicked(id) => { album-clicked(id) => {
@@ -85,7 +109,7 @@ export component ExternalRecoView inherits VerticalLayout {
} }
} }
// Cold-start fallback — Qobuz editorial top artists (artist carousel). // Cold-start fallback — Qobuz editorial top artists.
if ExternalRecoState.top-artists.length > 0: ArtistCarousel { if ExternalRecoState.top-artists.length > 0: ArtistCarousel {
title: @tr("Popular artists on Qobuz"); title: @tr("Popular artists on Qobuz");
artists: ExternalRecoState.top-artists; artists: ExternalRecoState.top-artists;
@@ -97,7 +121,7 @@ export component ExternalRecoView inherits VerticalLayout {
} }
} }
// Empty state: nothing loaded and not loading (no source, no editorial). // Empty state.
if !root.any-content && !ExternalRecoState.loading: VerticalLayout { if !root.any-content && !ExternalRecoState.loading: VerticalLayout {
spacing: 8px; spacing: 8px;
Text { Text {
+14 -10
View File
@@ -335,17 +335,21 @@ export global ForYouState {
export global ExternalRecoState { export global ExternalRecoState {
in property <bool> loading: false; in property <bool> loading: false;
in-out property <bool> loaded: false; in-out property <bool> loaded: false;
// True when no external integration is connected: the editorial fallback // True when no external source is connected: the editorial fallback
// (top-albums + top-artists) is shown instead of the personalized rows. // (top-albums + top-artists) shows instead of the personalized rows.
in property <bool> editorial-fallback: false; in property <bool> editorial-fallback: false;
// C1 — similar artists you haven't heard. // Recommended artists (Last.fm similar, not heard). subtitle = "Similar to…".
in property <[SlimItem]> similar-artists: []; in property <[SlimItem]> rec-artists: [];
// C2 — similar tracks you haven't heard. // Recommended albums (Last.fm artist top-albums, not scrobbled).
in property <[SlimItem]> similar-tracks: []; in property <DiscoverSection> rec-albums;
// C3 — listened but not recently. // Fresh releases (ListenBrainz, from artists you follow).
in property <[SlimItem]> rediscover-tracks: []; in property <DiscoverSection> fresh-releases;
// C4 — from artists you know but not scrobbled (deep cuts). // Weekly Exploration (ListenBrainz discovery) tracks.
in property <[SlimItem]> deep-cut-tracks: []; in property <[SlimItem]> weekly-exploration: [];
// Weekly Jams (ListenBrainz familiar) tracks.
in property <[SlimItem]> weekly-jams: [];
// Deep-cut albums from artists you know.
in property <DiscoverSection> deep-cut-albums;
// Cold-start fallback: Qobuz editorial top albums. // Cold-start fallback: Qobuz editorial top albums.
in property <DiscoverSection> top-albums; in property <DiscoverSection> top-albums;
// Cold-start fallback: Qobuz editorial top artists. // Cold-start fallback: Qobuz editorial top artists.
+32 -21
View File
@@ -163,18 +163,15 @@ pub enum ArtworkTarget {
ForYouMoreFromLibrary { index: usize }, ForYouMoreFromLibrary { index: usize },
/// A card in ForYouState.rediscover.albums[index]. /// A card in ForYouState.rediscover.albums[index].
ForYouRediscover { index: usize }, ForYouRediscover { index: usize },
/// A tile in ExternalRecoState.similar-artists[index] (4th tab). /// 4th-tab "Recommendations" rows (external-reco engine).
ExtRecoSimilarArtist { index: usize }, ExtRecoRecArtist { index: usize },
/// A row in ExternalRecoState.similar-tracks[index].
ExtRecoSimilarTrack { index: usize },
/// A row in ExternalRecoState.rediscover-tracks[index].
ExtRecoRediscoverTrack { index: usize },
/// A row in ExternalRecoState.deep-cut-tracks[index].
ExtRecoDeepCutTrack { index: usize },
/// A card in ExternalRecoState.top-albums.albums[index] (cold-start).
ExtRecoTopAlbum { index: usize },
/// A tile in ExternalRecoState.top-artists[index] (cold-start).
ExtRecoTopArtist { index: usize }, ExtRecoTopArtist { index: usize },
ExtRecoRecAlbum { index: usize },
ExtRecoFreshAlbum { index: usize },
ExtRecoDeepAlbum { index: usize },
ExtRecoTopAlbum { index: usize },
ExtRecoWeeklyExploration { index: usize },
ExtRecoWeeklyJams { index: usize },
/// A card in ForYouState.favorite-albums.albums[index]. /// A card in ForYouState.favorite-albums.albums[index].
ForYouFavoriteAlbum { index: usize }, ForYouFavoriteAlbum { index: usize },
/// The Spotlight artist portrait. /// The Spotlight artist portrait.
@@ -1296,29 +1293,36 @@ fn apply_artwork(
model.set_row_data(index, item); model.set_row_data(index, item);
} }
} }
ArtworkTarget::ExtRecoSimilarArtist { index } => { ArtworkTarget::ExtRecoRecArtist { index } => {
let model = window.global::<crate::ExternalRecoState>().get_similar_artists(); let model = window.global::<crate::ExternalRecoState>().get_rec_artists();
if let Some(mut item) = model.row_data(index) { if let Some(mut item) = model.row_data(index) {
item.artwork = image; item.artwork = image;
model.set_row_data(index, item); model.set_row_data(index, item);
} }
} }
ArtworkTarget::ExtRecoSimilarTrack { index } => { ArtworkTarget::ExtRecoTopArtist { index } => {
let model = window.global::<crate::ExternalRecoState>().get_similar_tracks(); let model = window.global::<crate::ExternalRecoState>().get_top_artists();
if let Some(mut item) = model.row_data(index) { if let Some(mut item) = model.row_data(index) {
item.artwork = image; item.artwork = image;
model.set_row_data(index, item); model.set_row_data(index, item);
} }
} }
ArtworkTarget::ExtRecoRediscoverTrack { index } => { ArtworkTarget::ExtRecoRecAlbum { index } => {
let model = window.global::<crate::ExternalRecoState>().get_rediscover_tracks(); let model = window.global::<crate::ExternalRecoState>().get_rec_albums().albums;
if let Some(mut item) = model.row_data(index) { if let Some(mut item) = model.row_data(index) {
item.artwork = image; item.artwork = image;
model.set_row_data(index, item); model.set_row_data(index, item);
} }
} }
ArtworkTarget::ExtRecoDeepCutTrack { index } => { ArtworkTarget::ExtRecoFreshAlbum { index } => {
let model = window.global::<crate::ExternalRecoState>().get_deep_cut_tracks(); let model = window.global::<crate::ExternalRecoState>().get_fresh_releases().albums;
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
}
}
ArtworkTarget::ExtRecoDeepAlbum { index } => {
let model = window.global::<crate::ExternalRecoState>().get_deep_cut_albums().albums;
if let Some(mut item) = model.row_data(index) { if let Some(mut item) = model.row_data(index) {
item.artwork = image; item.artwork = image;
model.set_row_data(index, item); model.set_row_data(index, item);
@@ -1331,8 +1335,15 @@ fn apply_artwork(
model.set_row_data(index, item); model.set_row_data(index, item);
} }
} }
ArtworkTarget::ExtRecoTopArtist { index } => { ArtworkTarget::ExtRecoWeeklyExploration { index } => {
let model = window.global::<crate::ExternalRecoState>().get_top_artists(); let model = window.global::<crate::ExternalRecoState>().get_weekly_exploration();
if let Some(mut item) = model.row_data(index) {
item.artwork = image;
model.set_row_data(index, item);
}
}
ArtworkTarget::ExtRecoWeeklyJams { index } => {
let model = window.global::<crate::ExternalRecoState>().get_weekly_jams();
if let Some(mut item) = model.row_data(index) { if let Some(mut item) = model.row_data(index) {
item.artwork = image; item.artwork = image;
model.set_row_data(index, item); model.set_row_data(index, item);
+190 -119
View File
@@ -1,10 +1,13 @@
//! Discover > Recommendations (the 4th tab) controller. //! Discover > Recommendations (the 4th tab) controller.
//! //!
//! Wires the frontend-agnostic `qbz-external-reco` engine to the Slint frontend: //! Wires the `qbz-external-reco` engine to Slint: a RecoCatalog over QbzCore,
//! a [`RecoCatalog`] over `QbzCore`, the per-user resolution-cache lifecycle, //! the per-user resolution-cache lifecycle, the scrobbler-username gate, and a
//! the local-history gather from the reco store, the scrobbler-username gate, //! PROGRESSIVE apply — each row paints the moment its builder resolves (the For
//! and the apply into `ExternalRecoState`. Lazy-loads on first tab open (mirrors //! You branch pattern), so the tab fills in incrementally instead of all at once.
//! `ensure_for_you_loaded`). //!
//! Lineup: Recommended Artists + Recommended Albums (Last.fm), Fresh Releases +
//! Weekly Exploration/Jams (ListenBrainz), Deep-cut albums, and a Qobuz editorial
//! cold-start fallback.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -13,8 +16,10 @@ use std::time::{SystemTime, UNIX_EPOCH};
use async_trait::async_trait; use async_trait::async_trait;
use qbz_app::shell::AppRuntime; use qbz_app::shell::AppRuntime;
use qbz_external_reco::{ use qbz_external_reco::{
build_external_carousels, ArtistReco, ExternalCarousels, LastFmHandle, ListenBrainzHandle, build_deep_cut_albums, build_editorial, build_fresh_releases, build_rec_albums,
LocalHistory, RecoCache, RecoCatalog, RecoInputs, TrackReco, build_rec_artists, build_weekly_exploration, build_weekly_jams, gather_history, is_cold_start,
AlbumReco, ArtistReco, LastFmHandle, ListenBrainzHandle, LocalHistory, RecoCache, RecoCatalog,
RecoInputs, TrackReco,
}; };
use qbz_integrations::{LastFmClient, ListenBrainzClient, MusicBrainzClient}; use qbz_integrations::{LastFmClient, ListenBrainzClient, MusicBrainzClient};
use qbz_models::{Album, Artist, Track}; use qbz_models::{Album, Artist, Track};
@@ -24,25 +29,17 @@ use crate::adapter::SlintAdapter;
use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache}; use crate::artwork::{ArtworkJob, ArtworkTarget, ImageCache};
use crate::{AlbumCardItem, AppWindow, DiscoverSection, ExternalRecoState, SlimItem}; use crate::{AlbumCardItem, AppWindow, DiscoverSection, ExternalRecoState, SlimItem};
/// Per-user cache directory (the rec->Qobuz resolution cache lives at
/// `<dir>/external_reco_cache.db`). `None` outside an active session.
static CACHE_DIR: Mutex<Option<PathBuf>> = Mutex::new(None); static CACHE_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
/// Bind the per-user cache dir on session activation (next to
/// `crate::reco::init_for_user`). Opening the cache opportunistically cleans
/// expired rows.
pub fn init_for_user(base_dir: &Path) { pub fn init_for_user(base_dir: &Path) {
if let Ok(mut g) = CACHE_DIR.lock() { if let Ok(mut g) = CACHE_DIR.lock() {
*g = Some(base_dir.to_path_buf()); *g = Some(base_dir.to_path_buf());
} }
// Best-effort: open once to run cleanup; the per-build open reuses the file.
if let Ok(cache) = RecoCache::open_at(base_dir) { if let Ok(cache) = RecoCache::open_at(base_dir) {
let _ = cache.cleanup_expired(); let _ = cache.cleanup_expired();
} }
} }
/// Drop the per-user cache binding on logout. (Not strictly required: every
/// session activation re-binds via `init_for_user`; kept for symmetry.)
#[allow(dead_code)] #[allow(dead_code)]
pub fn teardown() { pub fn teardown() {
if let Ok(mut g) = CACHE_DIR.lock() { if let Ok(mut g) = CACHE_DIR.lock() {
@@ -50,9 +47,7 @@ pub fn teardown() {
} }
} }
// --------------------------------------------------------------------------- // ── RecoCatalog over QbzCore (errors -> empty) ──────────────────────────────
// RecoCatalog over QbzCore (errors -> empty; "no data" is never an error).
// ---------------------------------------------------------------------------
struct CoreRecoCatalog { struct CoreRecoCatalog {
runtime: Arc<AppRuntime<SlintAdapter>>, runtime: Arc<AppRuntime<SlintAdapter>>,
@@ -68,7 +63,6 @@ impl RecoCatalog for CoreRecoCatalog {
.map(|p| p.items) .map(|p| p.items)
.unwrap_or_default() .unwrap_or_default()
} }
async fn search_artists(&self, query: &str, limit: usize) -> Vec<Artist> { async fn search_artists(&self, query: &str, limit: usize) -> Vec<Artist> {
self.runtime self.runtime
.core() .core()
@@ -77,7 +71,14 @@ impl RecoCatalog for CoreRecoCatalog {
.map(|p| p.items) .map(|p| p.items)
.unwrap_or_default() .unwrap_or_default()
} }
async fn search_albums(&self, query: &str, limit: usize) -> Vec<Album> {
self.runtime
.core()
.search_albums(query, limit as u32, 0, None)
.await
.map(|p| p.items)
.unwrap_or_default()
}
async fn artist_top_tracks(&self, artist_id: u64, limit: usize) -> Vec<Track> { async fn artist_top_tracks(&self, artist_id: u64, limit: usize) -> Vec<Track> {
self.runtime self.runtime
.core() .core()
@@ -86,7 +87,14 @@ impl RecoCatalog for CoreRecoCatalog {
.map(|c| c.items) .map(|c| c.items)
.unwrap_or_default() .unwrap_or_default()
} }
async fn artist_albums(&self, artist_id: u64, limit: usize) -> Vec<Album> {
self.runtime
.core()
.get_artist_albums(artist_id, Some(limit as u32), Some(0))
.await
.map(|a| a.items)
.unwrap_or_default()
}
async fn featured_albums(&self, kind: &str, limit: usize) -> Vec<Album> { async fn featured_albums(&self, kind: &str, limit: usize) -> Vec<Album> {
self.runtime self.runtime
.core() .core()
@@ -95,18 +103,13 @@ impl RecoCatalog for CoreRecoCatalog {
.map(|p| p.items) .map(|p| p.items)
.unwrap_or_default() .unwrap_or_default()
} }
async fn get_artist(&self, artist_id: u64) -> Option<Artist> { async fn get_artist(&self, artist_id: u64) -> Option<Artist> {
self.runtime.core().get_artist(artist_id).await.ok() self.runtime.core().get_artist(artist_id).await.ok()
} }
} }
// --------------------------------------------------------------------------- // ── Loader ──────────────────────────────────────────────────────────────────
// Loader
// ---------------------------------------------------------------------------
/// Lazy-load the Recommendations tab the first time it is opened. No-op once
/// loaded (the data persists for the session).
pub fn ensure_loaded( pub fn ensure_loaded(
runtime: &Arc<AppRuntime<SlintAdapter>>, runtime: &Arc<AppRuntime<SlintAdapter>>,
weak: &slint::Weak<AppWindow>, weak: &slint::Weak<AppWindow>,
@@ -139,16 +142,11 @@ fn spawn(
handle.spawn(async move { handle.spawn(async move {
let cfg = crate::scrobbler_settings::get(); let cfg = crate::scrobbler_settings::get();
// Read clients (public reads — Last.fm via the proxy by username, LB by
// username with an optional token for higher limits, MB direct).
let lastfm_client = LastFmClient::new(); let lastfm_client = LastFmClient::new();
let lb_client = ListenBrainzClient::new(); let lb_client = ListenBrainzClient::new();
if cfg.listenbrainz_is_authed() { if cfg.listenbrainz_is_authed() {
lb_client lb_client
.restore_token( .restore_token(cfg.listenbrainz_token.clone(), cfg.listenbrainz_username.clone())
cfg.listenbrainz_token.clone(),
cfg.listenbrainz_username.clone(),
)
.await; .await;
} }
let mb_client = MusicBrainzClient::new(); let mb_client = MusicBrainzClient::new();
@@ -172,16 +170,12 @@ fn spawn(
let local = LocalHistory { let local = LocalHistory {
known_artist_ids: crate::reco::known_artist_ids(2).unwrap_or_default(), known_artist_ids: crate::reco::known_artist_ids(2).unwrap_or_default(),
played_track_ids: crate::reco::recent_track_ids(2000) ..Default::default()
.map(|v| v.into_iter().collect())
.unwrap_or_default(),
}; };
let catalog = CoreRecoCatalog { let catalog = CoreRecoCatalog {
runtime: runtime.clone(), runtime: runtime.clone(),
}; };
// Open the resolution cache for this build (reuses the on-disk file).
let cache = CACHE_DIR let cache = CACHE_DIR
.lock() .lock()
.ok() .ok()
@@ -199,8 +193,39 @@ fn spawn(
rotation_seed: rotation_seed(), rotation_seed: rotation_seed(),
}; };
let result = build_external_carousels(inputs).await; if is_cold_start(&inputs) {
apply(&weak, &image_cache, result); let (albums, artists) = build_editorial(&inputs).await;
apply_albums(&weak, &image_cache, albums, AlbumRow::TopAlbums);
apply_artists(&weak, &image_cache, artists, ArtistRow::TopArtists);
} else {
let history = gather_history(&inputs).await;
// Progressive: each branch paints its row the moment it resolves.
let b_artists = async {
let r = build_rec_artists(&inputs, &history).await;
apply_artists(&weak, &image_cache, r, ArtistRow::RecArtists);
};
let b_albums = async {
let r = build_rec_albums(&inputs, &history).await;
apply_albums(&weak, &image_cache, r, AlbumRow::RecAlbums);
};
let b_fresh = async {
let r = build_fresh_releases(&inputs).await;
apply_albums(&weak, &image_cache, r, AlbumRow::FreshReleases);
};
let b_explore = async {
let r = build_weekly_exploration(&inputs).await;
apply_tracks(&weak, &image_cache, r, TrackRow::WeeklyExploration);
};
let b_jams = async {
let r = build_weekly_jams(&inputs).await;
apply_tracks(&weak, &image_cache, r, TrackRow::WeeklyJams);
};
let b_deep = async {
let r = build_deep_cut_albums(&inputs).await;
apply_albums(&weak, &image_cache, r, AlbumRow::DeepCuts);
};
tokio::join!(b_artists, b_albums, b_fresh, b_explore, b_jams, b_deep);
}
let _ = weak.upgrade_in_event_loop(|w| { let _ = weak.upgrade_in_event_loop(|w| {
let s = w.global::<ExternalRecoState>(); let s = w.global::<ExternalRecoState>();
@@ -210,22 +235,37 @@ fn spawn(
}); });
} }
// --------------------------------------------------------------------------- // ── Per-row apply (models built on the UI thread; slint::Image is !Send) ────
// Slint model mappers + apply
// --------------------------------------------------------------------------- #[derive(Clone, Copy)]
enum ArtistRow {
RecArtists,
TopArtists,
}
#[derive(Clone, Copy)]
enum AlbumRow {
RecAlbums,
FreshReleases,
DeepCuts,
TopAlbums,
}
#[derive(Clone, Copy)]
enum TrackRow {
WeeklyExploration,
WeeklyJams,
}
fn slim_from_artist(a: &ArtistReco) -> SlimItem { fn slim_from_artist(a: &ArtistReco) -> SlimItem {
SlimItem { SlimItem {
id: a.qobuz_artist_id.to_string().into(), id: a.qobuz_artist_id.to_string().into(),
title: a.name.clone().into(), title: a.name.clone().into(),
subtitle: "".into(), subtitle: a.subtitle.clone().into(),
rank: "".into(), rank: "".into(),
artwork_url: a.image_url.clone().into(), artwork_url: a.image_url.clone().into(),
artwork: slint::Image::default(), artwork: slint::Image::default(),
following: false, following: false,
} }
} }
fn slim_from_track(t: &TrackReco) -> SlimItem { fn slim_from_track(t: &TrackReco) -> SlimItem {
SlimItem { SlimItem {
id: t.qobuz_track_id.to_string().into(), id: t.qobuz_track_id.to_string().into(),
@@ -237,8 +277,7 @@ fn slim_from_track(t: &TrackReco) -> SlimItem {
following: false, following: false,
} }
} }
fn album_card(a: &AlbumReco) -> AlbumCardItem {
fn album_card(a: &qbz_external_reco::AlbumReco) -> AlbumCardItem {
AlbumCardItem { AlbumCardItem {
id: a.qobuz_album_id.clone().into(), id: a.qobuz_album_id.clone().into(),
title: a.title.clone().into(), title: a.title.clone().into(),
@@ -256,84 +295,116 @@ fn album_card(a: &qbz_external_reco::AlbumReco) -> AlbumCardItem {
} }
} }
fn apply(weak: &slint::Weak<AppWindow>, cache: &ImageCache, data: ExternalCarousels) { fn apply_artists(
// Artwork jobs built from the resolved rows (owned String urls). weak: &slint::Weak<AppWindow>,
let mut jobs: Vec<ArtworkJob> = Vec::new(); cache: &ImageCache,
let push_artist = |jobs: &mut Vec<ArtworkJob>, rows: &[ArtistReco], f: fn(usize) -> ArtworkTarget| { rows: Vec<ArtistReco>,
for (i, a) in rows.iter().enumerate() { which: ArtistRow,
if !a.image_url.is_empty() { ) {
jobs.push(ArtworkJob { let jobs: Vec<ArtworkJob> = rows
url: a.image_url.clone(), .iter()
target: f(i), .enumerate()
}); .filter(|(_, a)| !a.image_url.is_empty())
} .map(|(i, a)| ArtworkJob {
} url: a.image_url.clone(),
}; target: match which {
let push_track = |jobs: &mut Vec<ArtworkJob>, rows: &[TrackReco], f: fn(usize) -> ArtworkTarget| { ArtistRow::RecArtists => ArtworkTarget::ExtRecoRecArtist { index: i },
for (i, t) in rows.iter().enumerate() { ArtistRow::TopArtists => ArtworkTarget::ExtRecoTopArtist { index: i },
if !t.artwork_url.is_empty() { },
jobs.push(ArtworkJob { })
url: t.artwork_url.clone(), .collect();
target: f(i),
});
}
}
};
push_artist(&mut jobs, &data.similar_artists, |i| {
ArtworkTarget::ExtRecoSimilarArtist { index: i }
});
push_track(&mut jobs, &data.similar_tracks, |i| {
ArtworkTarget::ExtRecoSimilarTrack { index: i }
});
push_track(&mut jobs, &data.rediscover_tracks, |i| {
ArtworkTarget::ExtRecoRediscoverTrack { index: i }
});
push_track(&mut jobs, &data.deep_cut_tracks, |i| {
ArtworkTarget::ExtRecoDeepCutTrack { index: i }
});
for (i, a) in data.top_albums.iter().enumerate() {
if !a.artwork_url.is_empty() {
jobs.push(ArtworkJob {
url: a.artwork_url.clone(),
target: ArtworkTarget::ExtRecoTopAlbum { index: i },
});
}
}
push_artist(&mut jobs, &data.top_artists, |i| {
ArtworkTarget::ExtRecoTopArtist { index: i }
});
// Convert to Slint models INSIDE the event-loop closure: SlimItem /
// AlbumCardItem hold a slint::Image (!Send), so they cannot cross the thread
// boundary. `data` is plain String/u64 (Send) — move it in and map on the UI
// thread (mirrors foryou.rs apply_*).
let w = weak.clone(); let w = weak.clone();
let _ = w.upgrade_in_event_loop(move |w| { let _ = w.upgrade_in_event_loop(move |w| {
let model = ModelRc::new(VecModel::from(
rows.iter().map(slim_from_artist).collect::<Vec<_>>(),
));
let s = w.global::<ExternalRecoState>(); let s = w.global::<ExternalRecoState>();
s.set_editorial_fallback(data.editorial_fallback); match which {
s.set_similar_artists(ModelRc::new(VecModel::from( ArtistRow::RecArtists => s.set_rec_artists(model),
data.similar_artists.iter().map(slim_from_artist).collect::<Vec<_>>(), ArtistRow::TopArtists => s.set_top_artists(model),
))); }
s.set_similar_tracks(ModelRc::new(VecModel::from( });
data.similar_tracks.iter().map(slim_from_track).collect::<Vec<_>>(), crate::artwork::spawn_loads(jobs, weak.clone(), cache.clone());
))); }
s.set_rediscover_tracks(ModelRc::new(VecModel::from(
data.rediscover_tracks.iter().map(slim_from_track).collect::<Vec<_>>(), fn apply_tracks(
))); weak: &slint::Weak<AppWindow>,
s.set_deep_cut_tracks(ModelRc::new(VecModel::from( cache: &ImageCache,
data.deep_cut_tracks.iter().map(slim_from_track).collect::<Vec<_>>(), rows: Vec<TrackReco>,
))); which: TrackRow,
s.set_top_artists(ModelRc::new(VecModel::from( ) {
data.top_artists.iter().map(slim_from_artist).collect::<Vec<_>>(), let jobs: Vec<ArtworkJob> = rows
))); .iter()
s.set_top_albums(DiscoverSection { .enumerate()
title: qbz_i18n::t("Top albums on Qobuz").into(), .filter(|(_, t)| !t.artwork_url.is_empty())
.map(|(i, t)| ArtworkJob {
url: t.artwork_url.clone(),
target: match which {
TrackRow::WeeklyExploration => ArtworkTarget::ExtRecoWeeklyExploration { index: i },
TrackRow::WeeklyJams => ArtworkTarget::ExtRecoWeeklyJams { index: i },
},
})
.collect();
let w = weak.clone();
let _ = w.upgrade_in_event_loop(move |w| {
let model = ModelRc::new(VecModel::from(
rows.iter().map(slim_from_track).collect::<Vec<_>>(),
));
let s = w.global::<ExternalRecoState>();
match which {
TrackRow::WeeklyExploration => s.set_weekly_exploration(model),
TrackRow::WeeklyJams => s.set_weekly_jams(model),
}
});
crate::artwork::spawn_loads(jobs, weak.clone(), cache.clone());
}
fn album_row_title(which: AlbumRow) -> String {
match which {
AlbumRow::RecAlbums => qbz_i18n::t("Recommended Albums"),
AlbumRow::FreshReleases => qbz_i18n::t("Fresh Releases"),
AlbumRow::DeepCuts => qbz_i18n::t("Deep cuts from artists you know"),
AlbumRow::TopAlbums => qbz_i18n::t("Top albums on Qobuz"),
}
}
fn apply_albums(
weak: &slint::Weak<AppWindow>,
cache: &ImageCache,
rows: Vec<AlbumReco>,
which: AlbumRow,
) {
let jobs: Vec<ArtworkJob> = rows
.iter()
.enumerate()
.filter(|(_, a)| !a.artwork_url.is_empty())
.map(|(i, a)| ArtworkJob {
url: a.artwork_url.clone(),
target: match which {
AlbumRow::RecAlbums => ArtworkTarget::ExtRecoRecAlbum { index: i },
AlbumRow::FreshReleases => ArtworkTarget::ExtRecoFreshAlbum { index: i },
AlbumRow::DeepCuts => ArtworkTarget::ExtRecoDeepAlbum { index: i },
AlbumRow::TopAlbums => ArtworkTarget::ExtRecoTopAlbum { index: i },
},
})
.collect();
let title = album_row_title(which);
let w = weak.clone();
let _ = w.upgrade_in_event_loop(move |w| {
let section = DiscoverSection {
title: title.into(),
endpoint: "".into(), endpoint: "".into(),
albums: ModelRc::new(VecModel::from( albums: ModelRc::new(VecModel::from(
data.top_albums.iter().map(album_card).collect::<Vec<_>>(), rows.iter().map(album_card).collect::<Vec<_>>(),
)), )),
}); };
let s = w.global::<ExternalRecoState>();
match which {
AlbumRow::RecAlbums => s.set_rec_albums(section),
AlbumRow::FreshReleases => s.set_fresh_releases(section),
AlbumRow::DeepCuts => s.set_deep_cut_albums(section),
AlbumRow::TopAlbums => s.set_top_albums(section),
}
}); });
crate::artwork::spawn_loads(jobs, weak.clone(), cache.clone()); crate::artwork::spawn_loads(jobs, weak.clone(), cache.clone());
} }
+3 -2
View File
@@ -242,8 +242,9 @@ pub fn known_artist_ids(play_threshold: u32) -> Option<HashSet<u64>> {
} }
/// Most-recently-played distinct Qobuz track ids (the local "already heard /// Most-recently-played distinct Qobuz track ids (the local "already heard
/// in-app" set, e.g. for the external-reco "not scrobbled" deep-cut filter). /// in-app" set). Kept for the external-reco filters; currently the deep-cut row
/// `None` when reco is cold/disabled. /// filters on album ids, so this is unused for now.
#[allow(dead_code)]
pub fn recent_track_ids(limit: u32) -> Option<Vec<u64>> { pub fn recent_track_ids(limit: u32) -> Option<Vec<u64>> {
let guard = RECO.lock().ok()?; let guard = RECO.lock().ok()?;
let store = guard.as_ref()?; let store = guard.as_ref()?;