Add discovery artist blacklist — block artists from all discovery playlists

- New discovery_artist_blacklist table with NOCASE name matching
- Filter blacklisted artists from all 6 discovery pool queries, hero
  endpoint, and recent releases via SQL subquery and Python set check
- Name-based filtering means one block covers all sources (Spotify/iTunes/Deezer)
- Hover any discovery track row → ✕ button to quick-block that artist
- 🚫 button on Discover hero opens management modal with search-to-add
  (powered by enhanced search) and list of blocked artists with unblock
- CRUD API: GET/POST/DELETE /api/discover/artist-blacklist
- Updated changelogs
pull/253/head
Broque Thomas 1 month ago
parent 7acf7a7d80
commit b194e1e15b

@ -245,6 +245,7 @@ class PersonalizedPlaylistsService:
WHERE release_date IS NOT NULL
AND CAST(SUBSTR(release_date, 1, 4) AS INTEGER) BETWEEN ? AND ?
AND source = ?
AND LOWER(artist_name) NOT IN (SELECT LOWER(artist_name) FROM discovery_artist_blacklist)
ORDER BY RANDOM()
LIMIT ?
""", (start_year, end_year, active_source, limit * 10))
@ -401,6 +402,7 @@ class PersonalizedPlaylistsService:
FROM discovery_pool
WHERE artist_genres IS NOT NULL
AND source = ?
AND LOWER(artist_name) NOT IN (SELECT LOWER(artist_name) FROM discovery_artist_blacklist)
""", (active_source,))
rows = cursor.fetchall()
@ -531,6 +533,7 @@ class PersonalizedPlaylistsService:
source
FROM discovery_pool
WHERE popularity >= 60 AND source = ?
AND LOWER(artist_name) NOT IN (SELECT LOWER(artist_name) FROM discovery_artist_blacklist)
ORDER BY popularity DESC, RANDOM()
LIMIT ?
""", (active_source, limit * 3))
@ -590,6 +593,7 @@ class PersonalizedPlaylistsService:
source
FROM discovery_pool
WHERE popularity < 40 AND source = ?
AND LOWER(artist_name) NOT IN (SELECT LOWER(artist_name) FROM discovery_artist_blacklist)
ORDER BY RANDOM()
LIMIT ?
""", (active_source, limit))
@ -628,6 +632,7 @@ class PersonalizedPlaylistsService:
source
FROM discovery_pool
WHERE source = ?
AND LOWER(artist_name) NOT IN (SELECT LOWER(artist_name) FROM discovery_artist_blacklist)
ORDER BY RANDOM()
LIMIT ?
""", (active_source, limit))
@ -808,6 +813,7 @@ class PersonalizedPlaylistsService:
source
FROM discovery_pool
WHERE (artist_name LIKE ? OR track_name LIKE ?) AND source = ?
AND LOWER(artist_name) NOT IN (SELECT LOWER(artist_name) FROM discovery_artist_blacklist)
ORDER BY RANDOM()
LIMIT ?
""", (f'%{category}%', f'%{category}%', active_source, limit))

@ -1156,6 +1156,21 @@ class MusicDatabase:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_file_path ON track_downloads (file_path)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_source ON track_downloads (source_username, source_filename)")
# Discovery artist blacklist — artists users never want to see in discovery
cursor.execute("""
CREATE TABLE IF NOT EXISTS discovery_artist_blacklist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
artist_name TEXT NOT NULL COLLATE NOCASE,
spotify_artist_id TEXT,
itunes_artist_id TEXT,
deezer_artist_id TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(artist_name)
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_dab_name ON discovery_artist_blacklist (artist_name COLLATE NOCASE)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_dab_spotify ON discovery_artist_blacklist (spotify_artist_id)")
logger.info("Discovery tables created successfully")
except Exception as e:
@ -8530,6 +8545,62 @@ class MusicDatabase:
logger.error(f"Error removing from blacklist: {e}")
return False
# ==================== Discovery Artist Blacklist Methods ====================
def add_to_discovery_blacklist(self, artist_name: str, spotify_id: str = None,
itunes_id: str = None, deezer_id: str = None) -> bool:
"""Block an artist from appearing in discovery results."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO discovery_artist_blacklist
(artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id)
VALUES (?, ?, ?, ?)
""", (artist_name.strip(), spotify_id, itunes_id, deezer_id))
conn.commit()
return True
except Exception as e:
logger.error(f"Error adding to discovery blacklist: {e}")
return False
def remove_from_discovery_blacklist(self, blacklist_id: int) -> bool:
"""Remove an artist from the discovery blacklist."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM discovery_artist_blacklist WHERE id = ?", (blacklist_id,))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error removing from discovery blacklist: {e}")
return False
def get_discovery_blacklist(self) -> list:
"""Get all blacklisted discovery artists."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id, created_at
FROM discovery_artist_blacklist ORDER BY created_at DESC
""")
return [dict(r) for r in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting discovery blacklist: {e}")
return []
def get_discovery_blacklist_names(self) -> set:
"""Get set of blacklisted artist names (lowercased) for fast filtering."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("SELECT LOWER(artist_name) FROM discovery_artist_blacklist")
return {r[0] for r in cursor.fetchall()}
except Exception as e:
logger.error(f"Error getting discovery blacklist names: {e}")
return set()
# ==================== Track Download Provenance Methods ====================
def record_track_download(self, file_path: str, source_service: str, source_username: str,

@ -20121,7 +20121,8 @@ def get_version_info():
"• Genius artist search returns multiple results for manual matching (#233)",
"• Genius API interval increased from 1.5s to 2s to reduce 429 rate limits",
"• MusicBrainz cache now visible in Cache Browser with browse, clear, and clear-failed-only options",
"• Cache Health popup shows MusicBrainz alongside other sources, 'Failed Lookups' clarified as MB-specific"
"• Cache Health popup shows MusicBrainz alongside other sources, 'Failed Lookups' clarified as MB-specific",
"• Block artists from discovery — hover any track in a discovery playlist and click ✕ to permanently exclude that artist"
]
},
{
@ -38368,6 +38369,11 @@ def get_discover_hero():
print(f"[Discover Hero] Found {len(valid_artists)} valid artists for source: {active_source}")
# Filter out blacklisted artists
blacklisted = database.get_discovery_blacklist_names()
if blacklisted:
valid_artists = [a for a in valid_artists if a.similar_artist_name.lower() not in blacklisted]
# Take top 10 (already ordered by least-recently-featured, then quality)
similar_artists = valid_artists[:10]
@ -38767,6 +38773,11 @@ def get_discover_recent_releases():
except Exception:
pass
# Filter out blacklisted artists
blacklisted = database.get_discovery_blacklist_names()
if blacklisted:
albums = [a for a in albums if a.get('artist_name', '').lower() not in blacklisted]
return jsonify({"success": True, "albums": albums, "source": active_source})
except Exception as e:
@ -39666,6 +39677,48 @@ def get_familiar_favorites():
print(f"Error getting familiar favorites playlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/artist-blacklist', methods=['GET'])
def get_discovery_artist_blacklist():
"""Get all blacklisted discovery artists."""
try:
database = get_database()
entries = database.get_discovery_blacklist()
return jsonify({"success": True, "entries": entries})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/artist-blacklist', methods=['POST'])
def add_discovery_artist_blacklist():
"""Block an artist from appearing in discovery results."""
try:
data = request.get_json() or {}
artist_name = data.get('artist_name', '').strip()
if not artist_name:
return jsonify({"success": False, "error": "artist_name is required"}), 400
database = get_database()
success = database.add_to_discovery_blacklist(
artist_name=artist_name,
spotify_id=data.get('spotify_artist_id'),
itunes_id=data.get('itunes_artist_id'),
deezer_id=data.get('deezer_artist_id'),
)
if success:
logger.info(f"Blocked artist from discovery: {artist_name}")
return jsonify({"success": success})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/artist-blacklist/<int:blacklist_id>', methods=['DELETE'])
def remove_discovery_artist_blacklist(blacklist_id):
"""Unblock an artist from discovery."""
try:
database = get_database()
success = database.remove_from_discovery_blacklist(blacklist_id)
return jsonify({"success": success})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/build-playlist/search-artists', methods=['GET'])
def search_artists_for_playlist():
"""Search for artists to use as seeds for custom playlist building"""

@ -2988,6 +2988,7 @@
<!-- Discover Page Help Button -->
<button class="tool-help-button discover-page-help-button" data-tool="discover-page"
title="Learn about the Discover page">?</button>
<button class="discover-blacklist-btn" onclick="openDiscoveryBlacklistModal()" title="Blocked Artists">🚫</button>
<div class="discover-hero-content">
<div class="discover-hero-info">

@ -3404,6 +3404,7 @@ const WHATS_NEW = {
'2.2': [
// Newest features first
{ title: 'Global Search Bar', desc: 'Spotlight-style search from any page — press / or Ctrl+K. Full enhanced search with source tabs, library badges, and playback' },
{ title: 'Block Artists from Discovery', desc: 'Block artists you never want to see in discovery playlists — hover any track and click ✕, or use the 🚫 button on the Discover hero to search and manage blocked artists', page: 'discover' },
{ title: 'MusicBrainz Cache in Browser', desc: 'MusicBrainz cache now visible in Cache Browser — browse, clear all, or clear failed lookups only. Cache Health shows MB alongside other sources' },
{ title: 'Wing It Mode', desc: 'Download or sync playlists without metadata discovery — uses raw track names directly. Great for obscure tracks not on Spotify/iTunes' },
{ title: 'Redesigned Notifications', desc: 'Compact pill toasts, notification bell with unread badge, history panel with last 50 notifications and Learn More links' },

@ -50190,7 +50190,8 @@ async function loadDiscoverPage() {
initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed)
loadDecadeBrowserTabs(), // Time Machine (tabbed by decade)
loadGenreBrowserTabs(), // Browse by Genre (tabbed by genre)
loadListenBrainzPlaylistsFromBackend() // Load ListenBrainz playlist states for persistence
loadListenBrainzPlaylistsFromBackend(), // Load ListenBrainz playlist states for persistence
loadDiscoveryBlacklist() // Blocked artists list
]);
// Check for active syncs after page load
@ -53934,6 +53935,7 @@ function renderCompactPlaylist(container, tracks) {
const durationMin = Math.floor(track.duration_ms / 60000);
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
const artistEsc = (track.artist_name || '').replace(/'/g, "\\'").replace(/"/g, '&quot;');
html += `
<div class="discover-playlist-track-compact" data-track-index="${index}">
@ -53947,6 +53949,7 @@ function renderCompactPlaylist(container, tracks) {
</div>
<div class="track-compact-album">${track.album_name}</div>
<div class="track-compact-duration">${duration}</div>
<button class="track-compact-block" onclick="event.stopPropagation(); blockDiscoveryArtist('${artistEsc}')" title="Block ${artistEsc} from discovery"></button>
</div>
`;
});
@ -53955,6 +53958,161 @@ function renderCompactPlaylist(container, tracks) {
container.innerHTML = html;
}
async function blockDiscoveryArtist(artistName) {
if (!confirm(`Block "${artistName}" from all discovery playlists?`)) return;
try {
const res = await fetch('/api/discover/artist-blacklist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_name: artistName })
});
const data = await res.json();
if (data.success) {
showToast(`Blocked ${artistName} from discovery`, 'success');
// Refresh all discovery sections to remove the artist
loadPersonalizedHiddenGems();
loadDiscoveryShuffle();
loadPersonalizedDailyMixes();
} else {
showToast(data.error || 'Failed to block artist', 'error');
}
} catch (e) {
showToast('Error blocking artist', 'error');
}
}
async function openDiscoveryBlacklistModal() {
if (document.getElementById('discovery-blacklist-modal-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'discovery-blacklist-modal-overlay';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
overlay.innerHTML = `
<div class="discover-blacklist-modal">
<div class="discover-blacklist-modal-header">
<h2>Blocked Artists</h2>
<p>These artists won't appear in any discovery playlist across all sources</p>
<button class="watch-all-close" onclick="document.getElementById('discovery-blacklist-modal-overlay').remove()">&times;</button>
</div>
<div class="discover-blacklist-modal-search">
<input type="text" id="dbl-search-input" placeholder="Search for an artist to block..." autocomplete="off">
<div id="dbl-search-results" class="dbl-search-results" style="display:none"></div>
</div>
<div class="discover-blacklist-modal-list" id="dbl-list">
<div class="discover-blacklist-empty">Loading...</div>
</div>
<div class="discover-blacklist-modal-footer">
<button class="watch-all-btn watch-all-btn-cancel" onclick="document.getElementById('discovery-blacklist-modal-overlay').remove()">Close</button>
</div>
</div>
`;
document.body.appendChild(overlay);
// Wire up search
let searchTimer = null;
const input = document.getElementById('dbl-search-input');
input.addEventListener('input', () => {
clearTimeout(searchTimer);
const q = input.value.trim();
if (q.length < 2) { document.getElementById('dbl-search-results').style.display = 'none'; return; }
searchTimer = setTimeout(() => _dblSearch(q), 300);
});
_dblLoadList();
}
async function _dblSearch(query) {
const resultsEl = document.getElementById('dbl-search-results');
if (!resultsEl) return;
try {
// Use existing enhanced search to find artists
const res = await fetch('/api/enhanced-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, limit: 8 })
});
const data = await res.json();
const artists = data.spotify_artists || data.artists || [];
if (artists.length === 0) {
resultsEl.innerHTML = '<div class="dbl-search-empty">No artists found</div>';
resultsEl.style.display = 'block';
return;
}
resultsEl.innerHTML = artists.map(a => {
const name = _escToast(a.name || '');
const img = a.image_url ? `<img src="${a.image_url}" class="dbl-search-img">` : '<div class="dbl-search-img-placeholder">🎤</div>';
return `<div class="dbl-search-item" onclick="_dblBlockFromSearch('${name.replace(/'/g, "\\'")}')">
${img}
<span class="dbl-search-name">${name}</span>
<span class="dbl-search-action">Block</span>
</div>`;
}).join('');
resultsEl.style.display = 'block';
} catch (e) {
resultsEl.style.display = 'none';
}
}
async function _dblBlockFromSearch(artistName) {
try {
const res = await fetch('/api/discover/artist-blacklist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_name: artistName })
});
const data = await res.json();
if (data.success) {
showToast(`Blocked ${artistName} from discovery`, 'success');
document.getElementById('dbl-search-results').style.display = 'none';
const input = document.getElementById('dbl-search-input');
if (input) input.value = '';
_dblLoadList();
}
} catch (e) {
showToast('Error blocking artist', 'error');
}
}
async function _dblLoadList() {
const container = document.getElementById('dbl-list');
if (!container) return;
try {
const res = await fetch('/api/discover/artist-blacklist');
const data = await res.json();
if (!data.success || !data.entries || data.entries.length === 0) {
container.innerHTML = '<div class="discover-blacklist-empty">No blocked artists yet — search above to block one</div>';
return;
}
container.innerHTML = data.entries.map(e => `
<div class="discover-blacklist-item">
<span class="discover-blacklist-name">${_escToast(e.artist_name)}</span>
<span class="discover-blacklist-date">${e.created_at ? new Date(e.created_at).toLocaleDateString() : ''}</span>
<button class="discover-blacklist-remove" onclick="unblockDiscoveryArtist(${e.id}, '${_escToast(e.artist_name).replace(/'/g, "\\'")}')" title="Unblock"></button>
</div>
`).join('');
} catch (e) {
container.innerHTML = '<div class="discover-blacklist-empty">Failed to load</div>';
}
}
async function unblockDiscoveryArtist(id, name) {
try {
const res = await fetch(`/api/discover/artist-blacklist/${id}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
showToast(`Unblocked ${name}`, 'success');
_dblLoadList();
}
} catch (e) {
showToast('Error unblocking artist', 'error');
}
}
// Backwards compat — called during page init but now a no-op (modal handles it)
function loadDiscoveryBlacklist() {}
async function loadDiscoveryShuffle() {
try {
const container = document.getElementById('personalized-discovery-shuffle');

@ -30061,6 +30061,201 @@ body.helper-mode-active #dashboard-activity-feed:hover {
text-align: right;
}
.track-compact-block {
display: none;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: rgba(255, 80, 80, 0.12);
color: rgba(255, 80, 80, 0.6);
font-size: 10px;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.track-compact-block:hover {
background: rgba(255, 80, 80, 0.25);
color: #ff5050;
}
.discover-playlist-track-compact:hover .track-compact-block {
display: flex;
align-items: center;
justify-content: center;
}
/* Discovery Blacklist Button (on hero) */
.discover-blacklist-btn {
position: absolute;
top: 20px;
right: 58px;
z-index: 10;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: rgba(0,0,0,0.4);
color: rgba(255,255,255,0.6);
font-size: 14px;
cursor: pointer;
backdrop-filter: blur(8px);
transition: all 0.15s;
}
.discover-blacklist-btn:hover {
background: rgba(255, 80, 80, 0.3);
color: #fff;
}
/* Discovery Blacklist Modal */
.discover-blacklist-modal {
background: #1a1a1a;
border-radius: 16px;
width: 500px;
max-width: 95vw;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 80px rgba(0,0,0,0.6);
}
.discover-blacklist-modal-header {
padding: 20px 24px 12px;
position: relative;
}
.discover-blacklist-modal-header h2 {
margin: 0 0 4px;
font-size: 18px;
color: #fff;
}
.discover-blacklist-modal-header p {
margin: 0;
font-size: 12px;
color: rgba(255,255,255,0.4);
}
.discover-blacklist-modal-search {
padding: 0 24px 12px;
position: relative;
}
.discover-blacklist-modal-search input {
width: 100%;
padding: 10px 14px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px;
color: #fff;
font-size: 13px;
outline: none;
box-sizing: border-box;
}
.discover-blacklist-modal-search input:focus {
border-color: rgba(255,255,255,0.15);
}
.dbl-search-results {
position: absolute;
left: 24px;
right: 24px;
background: #222;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.08);
max-height: 240px;
overflow-y: auto;
z-index: 10;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.dbl-search-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
cursor: pointer;
transition: background 0.1s;
}
.dbl-search-item:hover {
background: rgba(255,255,255,0.06);
}
.dbl-search-img {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.dbl-search-img-placeholder {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255,255,255,0.06);
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.dbl-search-name {
flex: 1;
font-size: 13px;
color: rgba(255,255,255,0.85);
}
.dbl-search-action {
font-size: 11px;
color: rgba(255, 80, 80, 0.7);
font-weight: 600;
}
.dbl-search-empty {
padding: 12px;
text-align: center;
color: rgba(255,255,255,0.3);
font-size: 12px;
}
.discover-blacklist-modal-list {
flex: 1;
overflow-y: auto;
padding: 0 24px;
max-height: 300px;
}
.discover-blacklist-modal-footer {
padding: 12px 24px;
display: flex;
justify-content: flex-end;
}
/* Discovery Artist Blacklist Items */
.discover-blacklist-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.discover-blacklist-item:last-child { border-bottom: none; }
.discover-blacklist-name {
flex: 1;
font-size: 13px;
color: rgba(255,255,255,0.8);
}
.discover-blacklist-date {
font-size: 11px;
color: rgba(255,255,255,0.25);
}
.discover-blacklist-remove {
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.3);
font-size: 10px;
cursor: pointer;
transition: all 0.15s;
}
.discover-blacklist-remove:hover {
background: rgba(255, 80, 80, 0.2);
color: #ff5050;
}
.discover-blacklist-empty {
padding: 20px;
text-align: center;
color: rgba(255,255,255,0.25);
font-size: 13px;
}
/* Discover Playlist Cards */
.discover-playlist-card {
background: #1a1a1a;

Loading…
Cancel
Save