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:
8 files changed
+243
-26
No files matched your search
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user