From e97d43d61adb02b42f907c6ff4d8a3bf1b801951 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:57:07 -0600 Subject: [PATCH] feat(reco): genre backfill + reco-backed Rediscover row Wire the reco store into the For You favorite-albums branch: - Genre backfill: when favorite albums resolve (full Album with genre), write each (genre_id, genre_name) onto its reco events + album-meta via spawn_blocking. Plays carry no genre, so this makes the engine's get_top_genres non-empty (Tauri never backfilled, so its top-genres was dead). - Rediscover: build_rediscover prefers the reco 'forgotten favorites' set (favorited, not played in 30d, from the Tauri-shared events.db) when warm, falling back to the local recents heuristic when cold so the row never empties (non-regressing for fresh stores, better for Tauri-migrants). cc --- crates/qbz/src/foryou.rs | 45 ++++++++++++++++++++++++++++++++++++---- crates/qbz/src/reco.rs | 33 +++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/crates/qbz/src/foryou.rs b/crates/qbz/src/foryou.rs index 5f36b682d..bc88c9118 100644 --- a/crates/qbz/src/foryou.rs +++ b/crates/qbz/src/foryou.rs @@ -420,11 +420,21 @@ fn top_artist_slims(fav_artists: &[Artist]) -> Vec { .collect() } -/// Rediscover — favorite albums not in the recent play-history. -fn build_rediscover(fav_albums: &[Album], recent_ids: &HashSet) -> Vec { +/// Rediscover — favorite albums the user hasn't returned to lately. Prefers the +/// reco store's "forgotten favorites" (favorited, not played in 30d, from the +/// Tauri-shared events.db) when warm; falls back to "not in the local recents +/// cache" when reco is cold so the row never empties. +fn build_rediscover( + fav_albums: &[Album], + recent_ids: &HashSet, + forgotten: Option<&HashSet>, +) -> Vec { fav_albums .iter() - .filter(|a| !recent_ids.contains(&a.id)) + .filter(|a| match forgotten { + Some(set) => set.contains(&a.id), + None => !recent_ids.contains(&a.id), + }) .take(18) .cloned() .map(map_album) @@ -854,7 +864,34 @@ pub fn spawn_for_you( Box::pin(async move { let fav_albums = fetch_fav_albums(&runtime).await; apply_favorite_albums(&weak, &cache, build_favorite_albums(&fav_albums)); - apply_rediscover(&weak, &cache, build_rediscover(&fav_albums, &recent_ids)); + // reco: backfill genres for the resolved favorite albums so the + // engine's top-genres has data (plays alone carry no genre). + let genre_entries: Vec<(String, u64, String)> = fav_albums + .iter() + .filter_map(|a| { + a.genre + .as_ref() + .filter(|g| g.id > 0) + .map(|g| (a.id.clone(), g.id, g.name.clone())) + }) + .collect(); + if !genre_entries.is_empty() { + tokio::task::spawn_blocking(move || { + crate::reco::backfill_album_genres(genre_entries) + }); + } + // reco: prefer the reco "forgotten favorites" set when the store + // is warm (shared events.db); fall back to the local recents + // heuristic when cold so the Rediscover row never empties. + let forgotten: Option> = + crate::reco::forgotten_favorite_album_ids(60, 30) + .filter(|ids| !ids.is_empty()) + .map(|ids| ids.into_iter().collect()); + apply_rediscover( + &weak, + &cache, + build_rediscover(&fav_albums, &recent_ids, forgotten.as_ref()), + ); apply_radio(&weak, &cache, build_radio(&recent_album_list, &fav_albums)); // Only the no-recent-history case needs the favorite-album seed; diff --git a/crates/qbz/src/reco.rs b/crates/qbz/src/reco.rs index 561898be5..2809fa86b 100644 --- a/crates/qbz/src/reco.rs +++ b/crates/qbz/src/reco.rs @@ -179,6 +179,39 @@ pub fn log_playlist_add(playlist_id: Option, track_ids: Vec) { } } +// --------------------------------------------------------------------------- +// Surfaces (W7/W8) +// --------------------------------------------------------------------------- + +/// Forgotten-favorite album ids (favorited, not played within `recency_days`). +/// `None` when reco is disabled or the read fails — the caller falls back to +/// its local heuristic so the Rediscover row never empties on a cold store. +pub fn forgotten_favorite_album_ids(limit: u32, recency_days: u32) -> Option> { + let guard = RECO.lock().ok()?; + let store = guard.as_ref()?; + store + .get_forgotten_favorite_album_ids(limit, recency_days) + .ok() +} + +/// Backfill genres `(album_id, genre_id, genre_name)` onto reco events + +/// album-meta once albums are resolved. Best-effort, blocking SQLite — call +/// from `spawn_blocking`. Plays carry no genre, so this is what feeds +/// `get_top_genres`; idempotent (only fills still-NULL event genres). +pub fn backfill_album_genres(entries: Vec<(String, u64, String)>) { + if entries.is_empty() { + return; + } + if let Ok(guard) = RECO.lock() { + if let Some(store) = guard.as_ref() { + for (album_id, genre_id, genre_name) in entries { + let _ = store.update_genre_for_album(&album_id, genre_id); + let _ = store.set_album_genre_name(&album_id, &genre_name); + } + } + } +} + // --------------------------------------------------------------------------- // Training (W5) // ---------------------------------------------------------------------------