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:
vicrodh committed 2026-06-27 09:13:05 -06:00
1 parent 0347e333a8
commit c6b4906a6b
8 files changed
+266 -70

No files matched your search

+36 -1
View File
@@ -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();
+70 -35
View File
@@ -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> {
+24 -5
View File
@@ -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,
+5 -3
View File
@@ -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).