feat: tag-scoped thumbs down for discovery recommendations

Add dismissal system for "You May Also Like" discovery artists. Dismissals
are scoped to the genre tag used for the search, so dismissing AC/DC from
electronic recommendations won't affect metal recommendations.

- New discovery_dismissals table in reco DB (tag + normalized artist name)
- v2_dismiss_discovery_artist command persists dismissal per tag
- Discovery pipeline filters dismissed artists for both primary and secondary tags
- Backend returns 8 results (frontend shows 6, keeps 2 reserves)
- Thumbs-down button appears on hover, instantly replaces with reserve
- Returns primaryTag in DiscoveryResponse for frontend dismiss context
This commit is contained in:
vicrodh committed 2026-03-08 22:49:06 -06:00
1 parent 9f709efdc6
commit a07ee71090
8 files changed
+243 -26

No files matched your search

+78 -8
View File
@@ -11533,7 +11533,14 @@ fn normalize_artist_name(name: &str) -> String {
/// 2. Search MB for artists tagged with the primary genre tag
/// 3. Filter: seed artist, known similar artists, local listening history
/// 4. Resolve on Qobuz (verify exact name match to avoid homonyms)
/// 5. Return top 6, minimum 5
/// 5. Return top 8, minimum 5 (frontend shows 6, keeps 2 reserves)
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveryResponse {
pub artists: Vec<crate::listenbrainz::DiscoveryArtist>,
pub primary_tag: String,
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_get_discovery_artists(
@@ -11544,7 +11551,7 @@ pub async fn v2_get_discovery_artists(
reco_state: State<'_, RecoState>,
bridge: State<'_, CoreBridgeState>,
blacklist_state: State<'_, BlacklistState>,
) -> Result<Vec<crate::listenbrainz::DiscoveryArtist>, String> {
) -> Result<DiscoveryResponse, String> {
log::info!(
"[Discovery] Starting pipeline for {} (MBID: {})",
seedArtistName,
@@ -11554,7 +11561,7 @@ pub async fn v2_get_discovery_artists(
// Step 1: Check MB is enabled
if !musicbrainz.client.is_enabled().await {
log::warn!("[Discovery] MusicBrainz is disabled, returning empty");
return Ok(Vec::new());
return Ok(DiscoveryResponse { artists: Vec::new(), primary_tag: String::new() });
}
// Step 2: Get seed artist's primary genre tag
@@ -11566,7 +11573,7 @@ pub async fn v2_get_discovery_artists(
if seed_tags.is_empty() {
log::warn!("[Discovery] No tags found for seed artist, returning empty");
return Ok(Vec::new());
return Ok(DiscoveryResponse { artists: Vec::new(), primary_tag: String::new() });
}
let primary_tag = &seed_tags[0];
@@ -11591,7 +11598,7 @@ pub async fn v2_get_discovery_artists(
);
if mb_results.artists.is_empty() {
return Ok(Vec::new());
return Ok(DiscoveryResponse { artists: Vec::new(), primary_tag: primary_tag.to_string() });
}
// Step 4: Build exclusion sets
@@ -11634,6 +11641,27 @@ pub async fn v2_get_discovery_artists(
}
};
// Step 4b: Load dismissed artists for this tag
let dismissed_names: HashSet<String> = {
let guard = reco_state.db.lock().await;
if let Some(db) = guard.as_ref() {
db.get_dismissed_artists_for_tag(&primary_tag.to_lowercase())
.unwrap_or_default()
.into_iter()
.collect()
} else {
HashSet::new()
}
};
if !dismissed_names.is_empty() {
log::debug!(
"[Discovery] {} dismissed artists for tag '{}'",
dismissed_names.len(),
primary_tag
);
}
// Step 5: Filter MB results
let mut candidates: Vec<(String, String)> = Vec::new(); // (mbid, name)
@@ -11653,6 +11681,10 @@ pub async fn v2_get_discovery_artists(
if local_known_names.contains(&normalized) {
continue;
}
// Skip dismissed artists for this tag
if dismissed_names.contains(&normalized) {
continue;
}
candidates.push((artist.id.clone(), artist.name.clone()));
}
@@ -11681,7 +11713,7 @@ pub async fn v2_get_discovery_artists(
let bridge_guard = bridge.try_get().await;
let mut results: Vec<crate::listenbrainz::DiscoveryArtist> = Vec::new();
let min_results = 5;
let max_results = 6;
let max_results = 8;
if let Some(ref core_bridge) = bridge_guard {
for (mbid, name) in &candidates {
@@ -11722,7 +11754,7 @@ pub async fn v2_get_discovery_artists(
}
} else {
log::warn!("[Discovery] CoreBridge not available");
return Ok(Vec::new());
return Ok(DiscoveryResponse { artists: Vec::new(), primary_tag: primary_tag.to_string() });
}
// Step 7: If not enough results with primary tag, try secondary tag
@@ -11734,6 +11766,19 @@ pub async fn v2_get_discovery_artists(
secondary_tag
);
// Load dismissals for secondary tag too
let secondary_dismissed: HashSet<String> = {
let guard = reco_state.db.lock().await;
if let Some(db) = guard.as_ref() {
db.get_dismissed_artists_for_tag(&secondary_tag.to_lowercase())
.unwrap_or_default()
.into_iter()
.collect()
} else {
HashSet::new()
}
};
if let Ok(secondary_results) = musicbrainz
.client
.search_artists_by_tag(secondary_tag, 30)
@@ -11753,6 +11798,8 @@ pub async fn v2_get_discovery_artists(
}
if similar_names_set.contains(&normalized)
|| local_known_names.contains(&normalized)
|| dismissed_names.contains(&normalized)
|| secondary_dismissed.contains(&normalized)
{
continue;
}
@@ -11819,5 +11866,28 @@ pub async fn v2_get_discovery_artists(
}
log::info!("[Discovery] Returning {} discovery artists", results.len());
Ok(results)
Ok(DiscoveryResponse { artists: results, primary_tag: primary_tag.to_string() })
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_dismiss_discovery_artist(
tag: String,
artistName: String,
reco_state: State<'_, RecoState>,
) -> Result<(), String> {
let normalized = normalize_artist_name(&artistName);
let tag_lower = tag.to_lowercase();
log::info!(
"[Discovery] Dismissing '{}' for tag '{}'",
normalized,
tag_lower
);
let guard = reco_state.db.lock().await;
if let Some(db) = guard.as_ref() {
db.dismiss_discovery_artist(&tag_lower, &normalized)?;
}
Ok(())
}
+1
View File
@@ -1541,6 +1541,7 @@ pub fn run() {
commands_v2::v2_remote_metadata_get_album,
commands_v2::v2_musicbrainz_get_artist_relationships,
commands_v2::v2_get_discovery_artists,
commands_v2::v2_dismiss_discovery_artist,
commands_v2::v2_lastfm_get_auth_url,
commands_v2::v2_lastfm_complete_auth,
commands_v2::v2_lastfm_is_authenticated,
+57
View File
@@ -101,6 +101,7 @@ impl RecoStoreDb {
// Migrations - run after base schema
self.migrate_add_genre_id()?;
self.migrate_add_meta_tables()?;
self.migrate_add_discovery_dismissals()?;
Ok(())
}
@@ -883,6 +884,62 @@ impl RecoStoreDb {
Ok(artists)
}
// ============ Discovery Dismissals ============
/// Migration to add discovery_dismissals table
fn migrate_add_discovery_dismissals(&self) -> Result<(), String> {
self.conn
.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS discovery_dismissals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tag TEXT NOT NULL,
artist_name_normalized TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(tag, artist_name_normalized)
);
CREATE INDEX IF NOT EXISTS idx_discovery_dismissals_tag
ON discovery_dismissals(tag);
"#,
)
.map_err(|e| format!("Failed to create discovery_dismissals table: {}", e))?;
Ok(())
}
/// Dismiss an artist for a specific tag (genre context)
pub fn dismiss_discovery_artist(&self, tag: &str, artist_name_normalized: &str) -> Result<(), String> {
let created_at = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
self.conn
.execute(
"INSERT OR IGNORE INTO discovery_dismissals (tag, artist_name_normalized, created_at) VALUES (?, ?, ?)",
params![tag, artist_name_normalized, created_at],
)
.map_err(|e| format!("Failed to dismiss discovery artist: {}", e))?;
Ok(())
}
/// Get all dismissed artist names for a specific tag
pub fn get_dismissed_artists_for_tag(&self, tag: &str) -> Result<Vec<String>, String> {
let mut stmt = self
.conn
.prepare("SELECT artist_name_normalized FROM discovery_dismissals WHERE tag = ?")
.map_err(|e| format!("Failed to prepare dismissed artists query: {}", e))?;
let rows = stmt
.query_map(params![tag], |row| row.get::<_, String>(0))
.map_err(|e| format!("Failed to query dismissed artists: {}", e))?;
let mut names = Vec::new();
for row in rows {
names.push(row.map_err(|e| format!("Failed to read dismissed artist row: {}", e))?);
}
Ok(names)
}
/// Clear all meta caches so entries re-resolve with fresh image URLs.
pub fn clear_meta_caches(&self) -> Result<(), String> {
self.conn