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
This commit is contained in:
vicrodh committed 2026-06-26 19:57:07 -06:00
1 parent bd887d755c
commit e97d43d61a
2 files changed
+74 -4

No files matched your search

+41 -4
View File
@@ -420,11 +420,21 @@ fn top_artist_slims(fav_artists: &[Artist]) -> Vec<ArtistSlim> {
.collect() .collect()
} }
/// Rediscover — favorite albums not in the recent play-history. /// Rediscover — favorite albums the user hasn't returned to lately. Prefers the
fn build_rediscover(fav_albums: &[Album], recent_ids: &HashSet<String>) -> Vec<AlbumCard> { /// 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<String>,
forgotten: Option<&HashSet<String>>,
) -> Vec<AlbumCard> {
fav_albums fav_albums
.iter() .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) .take(18)
.cloned() .cloned()
.map(map_album) .map(map_album)
@@ -854,7 +864,34 @@ pub fn spawn_for_you<A>(
Box::pin(async move { Box::pin(async move {
let fav_albums = fetch_fav_albums(&runtime).await; let fav_albums = fetch_fav_albums(&runtime).await;
apply_favorite_albums(&weak, &cache, build_favorite_albums(&fav_albums)); 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<HashSet<String>> =
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)); apply_radio(&weak, &cache, build_radio(&recent_album_list, &fav_albums));
// Only the no-recent-history case needs the favorite-album seed; // Only the no-recent-history case needs the favorite-album seed;
+33
View File
@@ -179,6 +179,39 @@ pub fn log_playlist_add(playlist_id: Option<u64>, track_ids: Vec<u64>) {
} }
} }
// ---------------------------------------------------------------------------
// 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<Vec<String>> {
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) // Training (W5)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------