feat(reco): common/recent split + round-robin + 48h results cache + toast
Owner-directed polish on the Recommendations tab to make it richer + faster + non-repetitive. - Common vs Recent split: the single "Recommended Artists" row over-represented a recent soundtrack binge (a live preview showed "The Newton Brothers" flooding the row with 8 film composers while the core metal was buried). Split into two rows seeded from DIFFERENT periods: "More like the artists you love" (overall top) and "Based on what you've been into lately" (1-month top) — so recent one-offs never pollute the core taste. = 7 rows now. - Round-robin per seed inside each artist row (re-added from the v1 engine; the rework had dropped it) + per-seed cap, so no single seed floods the carousel. - Results cache (48h TTL): caches the FULL built rows (serialized ExternalCarousels) per-user, keyed by connected sources. Tab open within 48h = INSTANT paint from cache, zero fetch; stale/missing = build + store. Rebuilds every 48h (fresh rotation) so it's never "eternally the same" and opens stop being slow. Distinct from the rec->Qobuz resolution cache (30d/7d). - Toast on a real fetch (cache miss/stale): "Generating recommendations…" so the wait is communicated; the skeleton stays; no toast on a cache hit. cargo check -p qbz green (6m44s). cc
This commit is contained in:
8 files changed
+266
-70
No files matched your search
@@ -14,6 +14,10 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
const FOUND_TTL_SECS: i64 = 30 * 24 * 60 * 60;
|
||||
/// TTL for negative (not-on-Qobuz) entries — 7 days.
|
||||
const MISS_TTL_SECS: i64 = 7 * 24 * 60 * 60;
|
||||
/// TTL for the cached BUILT result rows — 48 hours (fast opens + rotation: the
|
||||
/// tab paints instantly from cache within the window, and rebuilds every 48h so
|
||||
/// the content is never "eternally the same").
|
||||
const RESULTS_TTL_SECS: i64 = 48 * 60 * 60;
|
||||
|
||||
/// A cache lookup outcome.
|
||||
pub enum CacheLookup {
|
||||
@@ -47,7 +51,12 @@ impl RecoCache {
|
||||
fetched_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reco_qobuz_cache_fetched
|
||||
ON reco_qobuz_cache(fetched_at);",
|
||||
ON reco_qobuz_cache(fetched_at);
|
||||
CREATE TABLE IF NOT EXISTS reco_results (
|
||||
key TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
built_at INTEGER NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| format!("Failed to init reco cache schema: {}", e))?;
|
||||
Ok(Self { conn })
|
||||
@@ -101,6 +110,32 @@ impl RecoCache {
|
||||
);
|
||||
}
|
||||
|
||||
/// Get the cached BUILT result rows (JSON of `ExternalCarousels`) for `key`,
|
||||
/// IF still within the 48h TTL. `None` -> the caller must rebuild.
|
||||
pub fn get_results(&self, key: &str) -> Option<String> {
|
||||
let row: Option<(String, i64)> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT data, built_at FROM reco_results WHERE key = ?",
|
||||
params![key],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None);
|
||||
match row {
|
||||
Some((data, built_at)) if Self::now() - built_at <= RESULTS_TTL_SECS => Some(data),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store the built result rows (JSON) for `key`, stamped now.
|
||||
pub fn put_results(&self, key: &str, data: &str) {
|
||||
let _ = self.conn.execute(
|
||||
"INSERT OR REPLACE INTO reco_results (key, data, built_at) VALUES (?, ?, ?)",
|
||||
params![key, data, Self::now()],
|
||||
);
|
||||
}
|
||||
|
||||
/// Drop expired rows (both regimes). Safe to call opportunistically.
|
||||
pub fn cleanup_expired(&self) -> usize {
|
||||
let now = Self::now();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! arazzo workflow): top artists -> artist.getSimilar -> top albums. There is no
|
||||
//! recommendation endpoint, so recommendations are replicated from similarity.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use futures_util::stream::{self, StreamExt};
|
||||
@@ -144,15 +144,44 @@ pub async fn gather_history(inputs: &RecoInputs<'_>) -> ExtHistory {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recommended Artists (Last.fm similar of recent top, not heard) ──────────
|
||||
// ── Recommended Artists — common (overall top) vs recent (1-month top) ──────
|
||||
//
|
||||
// Split so a recent one-off binge (e.g. a soundtrack) can't pollute the core
|
||||
// taste row, and so the user gets more, better-targeted rows. Round-robin per
|
||||
// seed inside each row so no single seed floods the carousel.
|
||||
|
||||
pub async fn build_rec_artists(inputs: &RecoInputs<'_>, history: &ExtHistory) -> Vec<ArtistReco> {
|
||||
const PER_SEED_CAP: usize = 8;
|
||||
|
||||
/// Interleave per-seed candidate lists round-robin (fair representation).
|
||||
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
|
||||
}
|
||||
|
||||
async fn similar_artist_row(
|
||||
inputs: &RecoInputs<'_>,
|
||||
history: &ExtHistory,
|
||||
period: &str,
|
||||
) -> Vec<ArtistReco> {
|
||||
let Some(lf) = &inputs.lastfm else {
|
||||
return Vec::new();
|
||||
};
|
||||
let seeds: Vec<String> = lf
|
||||
.client
|
||||
.get_top_artists(&lf.username, "1month", 12)
|
||||
.get_top_artists(&lf.username, period, 12)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -177,48 +206,54 @@ pub async fn build_rec_artists(inputs: &RecoInputs<'_>, history: &ExtHistory) ->
|
||||
.collect()
|
||||
.await;
|
||||
|
||||
// Aggregate candidate -> the seeds that surfaced it (for the subtitle).
|
||||
let mut agg: HashMap<String, (String, Vec<String>, f32)> = HashMap::new();
|
||||
// One candidate list per seed, deduped globally (first seed wins), capped so
|
||||
// one seed cannot dominate; then round-robin interleaved.
|
||||
let mut seen_global: HashSet<String> = HashSet::new();
|
||||
let mut groups: Vec<Vec<ArtistCandidate>> = Vec::new();
|
||||
for (seed, sims) in sim_results {
|
||||
let mut list: Vec<ArtistCandidate> = Vec::new();
|
||||
for s in sims {
|
||||
let nk = normalize(&s.name);
|
||||
if nk.is_empty() || history.artist_names.contains(&nk) || seeds_norm.contains(&nk) {
|
||||
if nk.is_empty()
|
||||
|| history.artist_names.contains(&nk)
|
||||
|| seeds_norm.contains(&nk)
|
||||
|| !seen_global.insert(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 candidates: Vec<ArtistCandidate> = agg
|
||||
.into_values()
|
||||
.map(|(name, seeds, score)| {
|
||||
let subtitle = if seeds.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"Similar to {}",
|
||||
seeds.iter().take(2).cloned().collect::<Vec<_>>().join(", ")
|
||||
)
|
||||
};
|
||||
ArtistCandidate {
|
||||
name,
|
||||
list.push(ArtistCandidate {
|
||||
name: s.name,
|
||||
source: RecoSource::LastFm,
|
||||
score,
|
||||
subtitle,
|
||||
score: s.match_score as f32,
|
||||
subtitle: format!("Similar to {}", seed),
|
||||
});
|
||||
if list.len() >= PER_SEED_CAP {
|
||||
break;
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
candidates.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
|
||||
candidates.truncate(45);
|
||||
|
||||
}
|
||||
groups.push(list);
|
||||
}
|
||||
let candidates: Vec<ArtistCandidate> = round_robin(groups).into_iter().take(45).collect();
|
||||
let pool = validate_artist_pool(inputs.catalog, inputs.cache, candidates).await;
|
||||
rotate_take(pool, inputs.rotation_seed, DISPLAY_CAP)
|
||||
}
|
||||
|
||||
/// "More like the artists you love" — your COMMON taste (overall top).
|
||||
pub async fn build_rec_artists_common(
|
||||
inputs: &RecoInputs<'_>,
|
||||
history: &ExtHistory,
|
||||
) -> Vec<ArtistReco> {
|
||||
similar_artist_row(inputs, history, "overall").await
|
||||
}
|
||||
|
||||
/// "Based on what you've been into lately" — your RECENT taste (1-month top).
|
||||
pub async fn build_rec_artists_recent(
|
||||
inputs: &RecoInputs<'_>,
|
||||
history: &ExtHistory,
|
||||
) -> Vec<ArtistReco> {
|
||||
similar_artist_row(inputs, history, "1month").await
|
||||
}
|
||||
|
||||
// ── Recommended Albums (Last.fm: your artists' top albums, not scrobbled) ───
|
||||
|
||||
pub async fn build_rec_albums(inputs: &RecoInputs<'_>, history: &ExtHistory) -> Vec<AlbumReco> {
|
||||
|
||||
@@ -87,8 +87,17 @@ pub async fn gather_history(inputs: &RecoInputs<'_>) -> ExtHistory {
|
||||
|
||||
// ── 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_artists_common(
|
||||
inputs: &RecoInputs<'_>,
|
||||
history: &ExtHistory,
|
||||
) -> Vec<ArtistReco> {
|
||||
carousels::build_rec_artists_common(inputs, history).await
|
||||
}
|
||||
pub async fn build_rec_artists_recent(
|
||||
inputs: &RecoInputs<'_>,
|
||||
history: &ExtHistory,
|
||||
) -> Vec<ArtistReco> {
|
||||
carousels::build_rec_artists_recent(inputs, history).await
|
||||
}
|
||||
pub async fn build_rec_albums(inputs: &RecoInputs<'_>, history: &ExtHistory) -> Vec<AlbumReco> {
|
||||
carousels::build_rec_albums(inputs, history).await
|
||||
@@ -122,8 +131,17 @@ pub async fn build_external_carousels(inputs: RecoInputs<'_>) -> ExternalCarouse
|
||||
};
|
||||
}
|
||||
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),
|
||||
let (
|
||||
rec_artists_common,
|
||||
rec_artists_recent,
|
||||
rec_albums,
|
||||
fresh_releases,
|
||||
weekly_exploration,
|
||||
weekly_jams,
|
||||
deep_cut_albums,
|
||||
) = tokio::join!(
|
||||
build_rec_artists_common(&inputs, &history),
|
||||
build_rec_artists_recent(&inputs, &history),
|
||||
build_rec_albums(&inputs, &history),
|
||||
build_fresh_releases(&inputs),
|
||||
build_weekly_exploration(&inputs),
|
||||
@@ -132,7 +150,8 @@ pub async fn build_external_carousels(inputs: RecoInputs<'_>) -> ExternalCarouse
|
||||
);
|
||||
ExternalCarousels {
|
||||
editorial_fallback: false,
|
||||
rec_artists,
|
||||
rec_artists_common,
|
||||
rec_artists_recent,
|
||||
rec_albums,
|
||||
fresh_releases,
|
||||
weekly_exploration,
|
||||
|
||||
@@ -98,12 +98,14 @@ pub struct TrackReco {
|
||||
///
|
||||
/// Empty vecs self-hide their row in the view, so partial population is always
|
||||
/// safe — the controller paints each row independently as it resolves.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ExternalCarousels {
|
||||
/// No connected external source -> editorial fallback regime.
|
||||
pub editorial_fallback: bool,
|
||||
/// Recommended artists (Last.fm similar, not heard).
|
||||
pub rec_artists: Vec<ArtistReco>,
|
||||
/// Recommended artists from your COMMON taste (overall top -> similar, not heard).
|
||||
pub rec_artists_common: Vec<ArtistReco>,
|
||||
/// Recommended artists from your RECENT taste (1-month top -> similar, not heard).
|
||||
pub rec_artists_recent: Vec<ArtistReco>,
|
||||
/// Recommended albums (Last.fm artist top-albums, not scrobbled).
|
||||
pub rec_albums: Vec<AlbumReco>,
|
||||
/// Fresh releases (ListenBrainz, from artists you follow).
|
||||
|
||||
Reference in new issue
Block a user