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
@@ -5,7 +5,7 @@
import { setCustomImage, removeCustomImage as removeCustomImageFromStore } from '$lib/stores/customArtistImageStore';
import { t } from 'svelte-i18n';
import { cachedSrc } from '$lib/actions/cachedImage';
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart, Search, X, ChevronLeft, ChevronRight, Radio, MoreHorizontal, Info, Disc, Settings, CheckSquare, PanelRightClose } from 'lucide-svelte';
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart, Search, X, ChevronLeft, ChevronRight, Radio, MoreHorizontal, Info, Disc, Settings, CheckSquare, PanelRightClose, ThumbsDown } from 'lucide-svelte';
import {
isBlacklisted,
isEnabled as isFilteringEnabled,
@@ -261,7 +261,7 @@
let mbArtistMbid = $state<string | null>(null);
let mbAvailable = $state(true); // Assume available until proven otherwise
// Discovery: "You may also like" (ListenBrainz)
// Discovery: "You may also like" (MusicBrainz tag-based)
interface DiscoveryArtist {
mbid: string;
name: string;
@@ -270,7 +270,14 @@
similarityPercent: number;
qobuzId?: number;
}
interface DiscoveryResponse {
artists: DiscoveryArtist[];
primaryTag: string;
}
const DISCOVERY_VISIBLE_COUNT = 6;
let discoveryArtists = $state<DiscoveryArtist[]>([]);
let discoveryReserves = $state<DiscoveryArtist[]>([]);
let discoveryTag = $state('');
let discoveryLoading = $state(false);
let artistDetailEl = $state<HTMLDivElement | null>(null);
let aboutSection = $state<HTMLDivElement | null>(null);
@@ -909,23 +916,53 @@
discoveryLoading = true;
discoveryArtists = [];
discoveryReserves = [];
discoveryTag = '';
try {
const similarNames = similarArtists.map(sa => sa.name);
const results = await invoke<DiscoveryArtist[]>('v2_get_discovery_artists', {
const response = await invoke<DiscoveryResponse>('v2_get_discovery_artists', {
seedMbid: mbArtistMbid,
seedArtistName: artist.name,
similarArtistNames: similarNames
});
discoveryArtists = results || [];
const all = response?.artists || [];
discoveryTag = response?.primaryTag || '';
discoveryArtists = all.slice(0, DISCOVERY_VISIBLE_COUNT);
discoveryReserves = all.slice(DISCOVERY_VISIBLE_COUNT);
} catch (err) {
console.error('Failed to load discovery artists:', err);
discoveryArtists = [];
discoveryReserves = [];
} finally {
discoveryLoading = false;
}
}
async function dismissDiscoveryArtist(disc: DiscoveryArtist) {
if (!discoveryTag) return;
// Remove from visible list immediately
discoveryArtists = discoveryArtists.filter(a => a.mbid !== disc.mbid);
// Pull a reserve if available
if (discoveryReserves.length > 0) {
const replacement = discoveryReserves[0];
discoveryArtists = [...discoveryArtists, replacement];
discoveryReserves = discoveryReserves.slice(1);
}
// Persist the dismissal
try {
await invoke('v2_dismiss_discovery_artist', {
tag: discoveryTag,
artistName: disc.name
});
} catch (err) {
console.error('Failed to dismiss discovery artist:', err);
}
}
// Navigate to a related artist by searching Qobuz
async function navigateToRelatedArtist(name: string) {
try {
@@ -2678,7 +2715,7 @@
</section>
{/if}
<!-- Listeners also enjoy (ListenBrainz discovery) -->
<!-- Discovery: "You may also like" (MusicBrainz tag-based) -->
{#if mbAvailable && (discoveryLoading || discoveryArtists.length > 0)}
<section class="sidebar-section">
<h4 class="section-label">{$t('artist.youMayAlsoLike')}</h4>
@@ -2686,15 +2723,24 @@
{#if discoveryLoading}
<span class="placeholder-text">{$t('actions.loading')}</span>
{:else}
{#each discoveryArtists as disc}
<button
class="sidebar-artist-link"
onclick={() => disc.qobuzId && onTrackGoToArtist?.(disc.qobuzId)}
title={disc.name}
>
<User size={12} />
{disc.name}
</button>
{#each discoveryArtists as disc (disc.mbid)}
<div class="discovery-artist-row">
<button
class="sidebar-artist-link"
onclick={() => disc.qobuzId && onTrackGoToArtist?.(disc.qobuzId)}
title={disc.name}
>
<User size={12} />
{disc.name}
</button>
<button
class="discovery-dismiss-btn"
onclick={() => dismissDiscoveryArtist(disc)}
title={$t('artist.dismissRecommendation')}
>
<ThumbsDown size={12} />
</button>
</div>
{/each}
{/if}
</div>
@@ -2901,6 +2947,45 @@
color: var(--text-primary);
}
.discovery-artist-row {
display: flex;
align-items: center;
gap: 2px;
}
.discovery-artist-row .sidebar-artist-link {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.discovery-dismiss-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex-shrink: 0;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted);
opacity: 0;
transition: opacity 150ms ease, background 150ms ease, color 150ms ease;
}
.discovery-artist-row:hover .discovery-dismiss-btn {
opacity: 1;
}
.discovery-dismiss-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.relationship-group {
display: flex;
flex-direction: column;
+2 -1
View File
@@ -283,7 +283,8 @@
"customImageRemoved": "Eigenes Künstlerbild entfernt",
"customImageError": "Fehler beim Aktualisieren des Künstlerbilds",
"imageSaved": "Künstlerbild gespeichert",
"youMayAlsoLike": "DAS KÖNNTE DIR AUCH GEFALLEN"
"youMayAlsoLike": "DAS KÖNNTE DIR AUCH GEFALLEN",
"dismissRecommendation": "Keine gute Empfehlung"
},
"sort": {
"nameAZ": "Name (A-Z)",
+2 -1
View File
@@ -295,7 +295,8 @@
"customImageRemoved": "Custom artist image removed",
"customImageError": "Failed to update artist image",
"imageSaved": "Artist image saved",
"youMayAlsoLike": "YOU MAY ALSO LIKE"
"youMayAlsoLike": "YOU MAY ALSO LIKE",
"dismissRecommendation": "Not a good recommendation"
},
"sort": {
"nameAZ": "Name (A-Z)",
+2 -1
View File
@@ -295,7 +295,8 @@
"customImageRemoved": "Imagen personalizada eliminada",
"customImageError": "Error al actualizar imagen del artista",
"imageSaved": "Imagen del artista guardada",
"youMayAlsoLike": "TAMBIÉN TE PUEDE GUSTAR"
"youMayAlsoLike": "TAMBIÉN TE PUEDE GUSTAR",
"dismissRecommendation": "No es una buena recomendación"
},
"sort": {
"nameAZ": "Nombre (A-Z)",
+2 -1
View File
@@ -283,7 +283,8 @@
"customImageRemoved": "Image personnalisée supprimée",
"customImageError": "Erreur lors de la mise à jour de l'image",
"imageSaved": "Image de l'artiste enregistrée",
"youMayAlsoLike": "VOUS AIMEREZ AUSSI"
"youMayAlsoLike": "VOUS AIMEREZ AUSSI",
"dismissRecommendation": "Pas une bonne recommandation"
},
"sort": {
"nameAZ": "Nom (A-Z)",