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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
Reference in new issue
Block a user