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:
16 files changed
+1452
-653
No files matched your search
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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());
|
||||||
}
|
}
|
||||||
@@ -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()?;
|
||||||
|
|||||||
Reference in new issue
Block a user