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:
2 files changed
+74
-4
No files matched your search
@@ -420,11 +420,21 @@ fn top_artist_slims(fav_artists: &[Artist]) -> Vec<ArtistSlim> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rediscover — favorite albums not in the recent play-history.
|
||||
fn build_rediscover(fav_albums: &[Album], recent_ids: &HashSet<String>) -> Vec<AlbumCard> {
|
||||
/// 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<String>,
|
||||
forgotten: Option<&HashSet<String>>,
|
||||
) -> Vec<AlbumCard> {
|
||||
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<A>(
|
||||
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<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));
|
||||
|
||||
// Only the no-recent-history case needs the favorite-album seed;
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user