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
This commit is contained in:
Broque Thomas 2026-04-01 23:35:56 -07:00
parent 7acf7a7d80
commit b194e1e15b
7 changed files with 487 additions and 2 deletions

View file

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

View file

@ -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_file_path ON track_downloads (file_path)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_td_source ON track_downloads (source_username, source_filename)") 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") logger.info("Discovery tables created successfully")
except Exception as e: except Exception as e:
@ -8530,6 +8545,62 @@ class MusicDatabase:
logger.error(f"Error removing from blacklist: {e}") logger.error(f"Error removing from blacklist: {e}")
return False 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 ==================== # ==================== Track Download Provenance Methods ====================
def record_track_download(self, file_path: str, source_service: str, source_username: str, def record_track_download(self, file_path: str, source_service: str, source_username: str,

View file

@ -20121,7 +20121,8 @@ def get_version_info():
"• Genius artist search returns multiple results for manual matching (#233)", "• Genius artist search returns multiple results for manual matching (#233)",
"• Genius API interval increased from 1.5s to 2s to reduce 429 rate limits", "• 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", "• 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}") 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) # Take top 10 (already ordered by least-recently-featured, then quality)
similar_artists = valid_artists[:10] similar_artists = valid_artists[:10]
@ -38767,6 +38773,11 @@ def get_discover_recent_releases():
except Exception: except Exception:
pass 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}) return jsonify({"success": True, "albums": albums, "source": active_source})
except Exception as e: except Exception as e:
@ -39666,6 +39677,48 @@ def get_familiar_favorites():
print(f"Error getting familiar favorites playlist: {e}") print(f"Error getting familiar favorites playlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500 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']) @app.route('/api/discover/build-playlist/search-artists', methods=['GET'])
def search_artists_for_playlist(): def search_artists_for_playlist():
"""Search for artists to use as seeds for custom playlist building""" """Search for artists to use as seeds for custom playlist building"""

View file

@ -2988,6 +2988,7 @@
<!-- Discover Page Help Button --> <!-- Discover Page Help Button -->
<button class="tool-help-button discover-page-help-button" data-tool="discover-page" <button class="tool-help-button discover-page-help-button" data-tool="discover-page"
title="Learn about the Discover page">?</button> 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-content">
<div class="discover-hero-info"> <div class="discover-hero-info">

View file

@ -3404,6 +3404,7 @@ const WHATS_NEW = {
'2.2': [ '2.2': [
// Newest features first // 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: '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: '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: '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' }, { title: 'Redesigned Notifications', desc: 'Compact pill toasts, notification bell with unread badge, history panel with last 50 notifications and Learn More links' },

View file

@ -50190,7 +50190,8 @@ async function loadDiscoverPage() {
initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed) initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed)
loadDecadeBrowserTabs(), // Time Machine (tabbed by decade) loadDecadeBrowserTabs(), // Time Machine (tabbed by decade)
loadGenreBrowserTabs(), // Browse by Genre (tabbed by genre) 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 // Check for active syncs after page load
@ -53934,6 +53935,7 @@ function renderCompactPlaylist(container, tracks) {
const durationMin = Math.floor(track.duration_ms / 60000); const durationMin = Math.floor(track.duration_ms / 60000);
const durationSec = Math.floor((track.duration_ms % 60000) / 1000); const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
const artistEsc = (track.artist_name || '').replace(/'/g, "\\'").replace(/"/g, '&quot;');
html += ` html += `
<div class="discover-playlist-track-compact" data-track-index="${index}"> <div class="discover-playlist-track-compact" data-track-index="${index}">
@ -53947,6 +53949,7 @@ function renderCompactPlaylist(container, tracks) {
</div> </div>
<div class="track-compact-album">${track.album_name}</div> <div class="track-compact-album">${track.album_name}</div>
<div class="track-compact-duration">${duration}</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> </div>
`; `;
}); });
@ -53955,6 +53958,161 @@ function renderCompactPlaylist(container, tracks) {
container.innerHTML = html; 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() { async function loadDiscoveryShuffle() {
try { try {
const container = document.getElementById('personalized-discovery-shuffle'); const container = document.getElementById('personalized-discovery-shuffle');

View file

@ -30061,6 +30061,201 @@ body.helper-mode-active #dashboard-activity-feed:hover {
text-align: right; 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 Cards */
.discover-playlist-card { .discover-playlist-card {
background: #1a1a1a; background: #1a1a1a;