From b194e1e15b048f3fd2a6504b7e7482d6516b03d4 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:35:56 -0700 Subject: [PATCH] =?UTF-8?q?Add=20discovery=20artist=20blacklist=20?= =?UTF-8?q?=E2=80=94=20block=20artists=20from=20all=20discovery=20playlist?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- core/personalized_playlists.py | 6 + database/music_database.py | 71 ++++++++++++ web_server.py | 55 +++++++++- webui/index.html | 1 + webui/static/helper.js | 1 + webui/static/script.js | 160 ++++++++++++++++++++++++++- webui/static/style.css | 195 +++++++++++++++++++++++++++++++++ 7 files changed, 487 insertions(+), 2 deletions(-) diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py index 7ee2e598..6fd3c8cd 100644 --- a/core/personalized_playlists.py +++ b/core/personalized_playlists.py @@ -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)) diff --git a/database/music_database.py b/database/music_database.py index 7538cc2b..f1531c3f 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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, diff --git a/web_server.py b/web_server.py index e5b0f009..3a1125cf 100644 --- a/web_server.py +++ b/web_server.py @@ -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/', 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""" diff --git a/webui/index.html b/webui/index.html index 008876b6..e6273265 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2988,6 +2988,7 @@ +
diff --git a/webui/static/helper.js b/webui/static/helper.js index 54680c35..1c8bf1ba 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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' }, diff --git a/webui/static/script.js b/webui/static/script.js index 59fac271..e0f53dc1 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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, '"'); html += `
@@ -53947,6 +53949,7 @@ function renderCompactPlaylist(container, tracks) {
${track.album_name}
${duration}
+
`; }); @@ -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 = ` +
+
+

Blocked Artists

+

These artists won't appear in any discovery playlist across all sources

+ +
+ +
+
Loading...
+
+ +
+ `; + 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 = '
No artists found
'; + resultsEl.style.display = 'block'; + return; + } + resultsEl.innerHTML = artists.map(a => { + const name = _escToast(a.name || ''); + const img = a.image_url ? `` : '
🎤
'; + return `
+ ${img} + ${name} + Block +
`; + }).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 = '
No blocked artists yet — search above to block one
'; + return; + } + container.innerHTML = data.entries.map(e => ` +
+ ${_escToast(e.artist_name)} + ${e.created_at ? new Date(e.created_at).toLocaleDateString() : ''} + +
+ `).join(''); + } catch (e) { + container.innerHTML = '
Failed to load
'; + } +} + +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'); diff --git a/webui/static/style.css b/webui/static/style.css index f8d7314a..e5345e49 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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;