Blocklist Phase 1 (backfill + API + modal): the Blocklist button on the watchlist page

Completes Phase 1 on top of the backend (43c798a7):

- Cross-source backfill: core/blocklist/backfill.py is a pure injected-resolver
  core (resolve only missing sources, never raises); core/blocklist/runtime.py
  wires the real metadata clients with a confident name-match (exact
  significant-token equality; album/track also require the parent artist when
  both expose one — no wrong IDs hung on an entry). Resolution runs
  synchronously at add time, so a ban is cross-source from the first scan;
  the artist name-fallback in matching covers any gap.
- API: GET/POST/DELETE /api/blocklist (profile-scoped) + /api/blocklist/search
  (thin wrapper over the manual-match service search on the active source, so
  the modal needn't know the source). Add resolves the other sources before
  storing.
- Modal (webui/static/blocklist.js): tabbed Artists/Albums/Tracks in the
  revamp design language (accent light-edge, pill tabs, debounced search with
  spinner + out-of-order guard, per-result Block, "currently blocked" list
  with a match-status star and per-row remove). Opened by a new "Blocklist"
  button on the watchlist page, next to Download Origins.

Tests: 5 backfill (fill-missing-only, None/exception handling, arg shape) + 4
API (search proxy, add→backfill→list→delete round trip, validation). Modal
registered in the script-split onclick-coverage test; JS syntax-checked.
This commit is contained in:
BoulderBadgeDad 2026-06-07 15:25:52 -07:00
parent 43c798a76e
commit b6d78d015d
10 changed files with 642 additions and 1 deletions

View file

@ -0,0 +1,50 @@
"""Cross-source ID backfill for blocklist entries.
When a user blocks an item, the modal gives us the ID for ONE source (the one
they searched). For the ban to survive a source switch, we resolve the OTHER
sources' IDs too — matching the blocked artist/album/track by name on each
source and taking a confident hit.
The resolution is kept pure + injected so it tests without a network: callers
pass a ``resolvers`` map ``{source: fn(entity_type, name, parent_name) -> id |
None}``. ``core/blocklist/runtime.py`` wires the real metadata clients.
Honest about fragility (acknowledged in design): artist matching is reliable,
album/track cross-source matching is best-effort (editions, common titles), so
a resolver returning None just leaves that source unmatched the artist
name-fallback in matching.py covers artist gaps; album/track gaps mean that
ban only applies on sources where an ID resolved.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, Optional
from core.blocklist.matching import SOURCE_ID_FIELDS
def resolve_missing_ids(
entry: Dict[str, Any],
resolvers: Dict[str, Callable[..., Optional[str]]],
) -> Dict[str, str]:
"""Return ``{id_column: resolved_id}`` for the sources currently missing an
ID on ``entry``. Never raises a resolver that errors is skipped."""
out: Dict[str, str] = {}
entity_type = entry.get("entity_type")
name = entry.get("name")
parent = entry.get("parent_name")
if not entity_type or not name:
return out
for source, col in SOURCE_ID_FIELDS.items():
if entry.get(col):
continue # already known
fn = resolvers.get(source)
if not fn:
continue
try:
rid = fn(entity_type, name, parent)
except Exception:
rid = None
if rid:
out[col] = str(rid)
return out

82
core/blocklist/runtime.py Normal file
View file

@ -0,0 +1,82 @@
"""Wire real metadata clients to the blocklist backfill resolvers.
Resolves a blocked item's ID on each metadata source by searching that source
for the name and taking a confidently name-matched hit. Confidence = exact
significant-token match (drops articles/punctuation) so we never hang a wrong
ID on an entry. Albums/tracks additionally require the parent artist to match
when both sides expose one.
"""
from __future__ import annotations
import re
from typing import Any, Callable, Dict, Optional
from utils.logging_config import get_logger
logger = get_logger("blocklist.runtime")
_STOP = {"the", "a", "an", "feat", "ft", "featuring", "with"}
def _tokens(text: Any) -> frozenset:
words = re.sub(r"[^a-z0-9]+", " ", str(text or "").lower()).split()
return frozenset(w for w in words if w not in _STOP)
def _name_of(obj: Any) -> str:
if isinstance(obj, dict):
return str(obj.get("name") or obj.get("title") or "")
return str(getattr(obj, "name", None) or getattr(obj, "title", None) or "")
def _id_of(obj: Any) -> Optional[str]:
val = obj.get("id") if isinstance(obj, dict) else getattr(obj, "id", None)
return str(val) if val else None
def _confident(result_name: str, want_name: str) -> bool:
rt, wt = _tokens(result_name), _tokens(want_name)
return bool(rt) and rt == wt
def _make_resolver(source: str) -> Callable[..., Optional[str]]:
def resolve(entity_type: str, name: str, parent_name: Optional[str] = None) -> Optional[str]:
from core.metadata.registry import get_client_for_source
client = get_client_for_source(source)
if not client:
return None
method = {
"artist": "search_artists",
"album": "search_albums",
"track": "search_tracks",
}.get(entity_type)
fn = getattr(client, method, None) if method else None
if not fn:
return None
try:
results = fn(name, limit=5) or []
except Exception as e:
logger.debug("%s %s search failed for %r: %s", source, entity_type, name, e)
return None
for r in results:
if not _confident(_name_of(r), name):
continue
# For album/track, also require the artist to line up when known.
if entity_type in ("album", "track") and parent_name:
artists = (r.get("artists") if isinstance(r, dict) else getattr(r, "artists", None)) or []
cand_artists = " ".join(
a.get("name", "") if isinstance(a, dict) else str(a) for a in artists)
if _tokens(parent_name) and not (_tokens(parent_name) & _tokens(cand_artists)):
continue
rid = _id_of(r)
if rid:
return rid
return None
return resolve
def build_resolvers() -> Dict[str, Callable[..., Optional[str]]]:
"""Source→resolver map for core.blocklist.backfill.resolve_missing_ids."""
return {s: _make_resolver(s) for s in ("spotify", "itunes", "deezer", "musicbrainz")}

View file

@ -0,0 +1,48 @@
"""Cross-source ID backfill (pure resolver layer)."""
from __future__ import annotations
from core.blocklist.backfill import resolve_missing_ids
def _resolvers(**by_source):
# each value is the id that source returns (or None)
return {src: (lambda et, n, p, _v=v: _v) for src, v in by_source.items()}
def test_fills_only_missing_sources():
entry = {"entity_type": "artist", "name": "Drake", "spotify_id": "sp-known"}
resolvers = _resolvers(spotify="SHOULD-NOT-BE-USED", itunes="it-new", deezer="dz-new")
out = resolve_missing_ids(entry, resolvers)
assert out == {"itunes_id": "it-new", "deezer_id": "dz-new"} # spotify skipped (known)
def test_resolver_returning_none_leaves_source_unmatched():
entry = {"entity_type": "album", "name": "Some Album"}
resolvers = _resolvers(spotify="sp", itunes=None, deezer=None, musicbrainz="mb")
out = resolve_missing_ids(entry, resolvers)
assert out == {"spotify_id": "sp", "musicbrainz_id": "mb"}
def test_resolver_exception_is_swallowed():
def boom(et, n, p):
raise RuntimeError("source down")
entry = {"entity_type": "artist", "name": "X"}
out = resolve_missing_ids(entry, {"spotify": boom, "deezer": lambda et, n, p: "dz"})
assert out == {"deezer_id": "dz"}
def test_no_name_or_type_returns_empty():
assert resolve_missing_ids({"entity_type": "artist"}, _resolvers(spotify="x")) == {}
assert resolve_missing_ids({"name": "X"}, _resolvers(spotify="x")) == {}
def test_resolver_receives_type_name_and_parent():
seen = {}
def capture(et, n, p):
seen.update(entity_type=et, name=n, parent=p)
return "id1"
resolve_missing_ids(
{"entity_type": "album", "name": "Scorpion", "parent_name": "Drake"},
{"spotify": capture})
assert seen == {"entity_type": "album", "name": "Scorpion", "parent": "Drake"}

View file

@ -0,0 +1,61 @@
"""Blocklist HTTP API: search / add (+ synchronous cross-source backfill) /
list / delete, end to end through the Flask test client."""
from __future__ import annotations
from unittest.mock import patch
import pytest
web_server = pytest.importorskip("web_server")
@pytest.fixture()
def client(monkeypatch):
web_server.app.config["TESTING"] = True
monkeypatch.setattr(web_server, "get_current_profile_id", lambda: 1)
return web_server.app.test_client()
def test_search_proxies_active_source(client):
with patch.object(web_server, "_search_service", return_value=[
{"id": "drake-sp", "name": "Drake", "image": None, "extra": "", "provider": "spotify"}]):
r = client.get("/api/blocklist/search?type=artist&q=drake")
assert r.status_code == 200
body = r.get_json()
assert body["success"] and body["results"][0]["name"] == "Drake"
def test_add_resolves_other_sources_then_list_and_delete(client):
resolvers = {
"itunes": lambda et, n, p: "drake-it",
"deezer": lambda et, n, p: None,
"spotify": lambda et, n, p: None,
"musicbrainz": lambda et, n, p: None,
}
with patch("core.blocklist.runtime.build_resolvers", return_value=resolvers):
r = client.post("/api/blocklist", json={
"entity_type": "artist", "name": "Drake Test One",
"source": "spotify", "source_id": "drake-sp1"})
assert r.status_code == 200 and r.get_json()["success"]
eid = r.get_json()["id"]
rows = client.get("/api/blocklist?entity_type=artist").get_json()["entries"]
row = next(x for x in rows if x["id"] == eid)
assert row["spotify_id"] == "drake-sp1"
assert row["itunes_id"] == "drake-it" # resolved at add time
assert row["match_status"] == "matched"
assert client.delete(f"/api/blocklist/{eid}").get_json()["success"] is True
rows = client.get("/api/blocklist?entity_type=artist").get_json()["entries"]
assert all(x["id"] != eid for x in rows)
def test_add_requires_type_and_name(client):
r = client.post("/api/blocklist", json={"entity_type": "artist"})
assert r.status_code == 400
def test_invalid_entity_type_rejected(client):
assert client.get("/api/blocklist?entity_type=bogus").status_code == 400
assert client.get("/api/blocklist/search?type=bogus&q=x").status_code == 400

View file

@ -52,7 +52,7 @@ SPLIT_MODULES = [
# Other JS files that exist in static/ but are NOT part of the split
NON_SPLIT_JS = {"setup-wizard.js", "docs.js", "helper.js", "particles.js", "worker-orbs.js",
"enrichment-manager.js", "origin-history.js"}
"enrichment-manager.js", "origin-history.js", "blocklist.js"}
# Pre-existing duplicate helper functions that lived in the original monolith.
# In a plain <script> context the last-loaded declaration wins. These are NOT

View file

@ -27976,6 +27976,98 @@ def personalized_update_config(kind, variant=''):
return jsonify({"success": False, "error": str(e)}), 500
# ─── Unified blocklist (artist/album/track) — Phase 1 ───
# Distinct from /api/library/blacklist (download source skipping). Profile-
# scoped. On add, the other metadata sources' IDs are resolved synchronously
# (best-effort) so a ban survives a source switch immediately.
@app.route('/api/blocklist', methods=['GET'])
def get_blocklist():
try:
entity_type = request.args.get('entity_type')
if entity_type and entity_type not in ('artist', 'album', 'track'):
return jsonify({"success": False, "error": "invalid entity_type"}), 400
entries = get_database().get_blocklist(get_current_profile_id(), entity_type=entity_type)
return jsonify({"success": True, "entries": entries})
except Exception as e:
logger.error(f"Error getting blocklist: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/blocklist', methods=['POST'])
def add_blocklist():
try:
data = request.get_json() or {}
entity_type = (data.get('entity_type') or '').strip().lower()
name = (data.get('name') or '').strip()
if entity_type not in ('artist', 'album', 'track') or not name:
return jsonify({"success": False, "error": "entity_type and name are required"}), 400
# The source the user searched + its id for this item.
source = (data.get('source') or '').strip().lower()
source_id = (data.get('source_id') or '').strip() or None
ids = {'spotify_id': None, 'itunes_id': None, 'deezer_id': None, 'musicbrainz_id': None}
col = {'spotify': 'spotify_id', 'itunes': 'itunes_id',
'deezer': 'deezer_id', 'musicbrainz': 'musicbrainz_id'}.get(source)
if col and source_id:
ids[col] = source_id
# Resolve the OTHER sources now (best-effort) so the ban is cross-source
# from the first scan. Failures just leave a source unmatched.
try:
from core.blocklist.backfill import resolve_missing_ids
from core.blocklist.runtime import build_resolvers
probe = {'entity_type': entity_type, 'name': name,
'parent_name': data.get('parent_name'), **ids}
ids.update(resolve_missing_ids(probe, build_resolvers()))
except Exception as e:
logger.debug("blocklist add backfill skipped: %s", e)
new_id = get_database().add_blocklist_entry(
get_current_profile_id(), entity_type, name,
spotify_id=ids['spotify_id'], itunes_id=ids['itunes_id'],
deezer_id=ids['deezer_id'], musicbrainz_id=ids['musicbrainz_id'],
parent_name=data.get('parent_name'))
if not new_id:
return jsonify({"success": False, "error": "Could not add entry"}), 500
logger.info("Blocklisted %s '%s'", entity_type, name)
return jsonify({"success": True, "id": new_id})
except Exception as e:
logger.error(f"Error adding blocklist entry: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/blocklist/search', methods=['GET'])
def search_blocklist_candidates():
"""Search the active metadata source for an artist/album/track to block.
Thin wrapper over the manual-match service search so the modal doesn't need
to know which source is active."""
try:
entity_type = (request.args.get('type') or 'artist').strip().lower()
if entity_type not in ('artist', 'album', 'track'):
return jsonify({"success": False, "error": "invalid type"}), 400
query = (request.args.get('q') or '').strip()
if not query:
return jsonify({"success": True, "results": []})
from core.metadata.registry import get_primary_source
source = get_primary_source() or 'spotify'
results = _search_service(source, entity_type, query)
return jsonify({"success": True, "source": source, "results": results})
except Exception as e:
logger.error(f"Error searching blocklist candidates: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/blocklist/<int:entry_id>', methods=['DELETE'])
def remove_blocklist(entry_id):
try:
ok = get_database().remove_blocklist_entry(get_current_profile_id(), entry_id)
return jsonify({"success": ok})
except Exception as e:
logger.error(f"Error removing blocklist entry: {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."""

View file

@ -8146,6 +8146,7 @@
<script src="{{ url_for('static', filename='track-detail.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='wishlist-tools.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='origin-history.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='blocklist.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script>

View file

@ -2047,6 +2047,13 @@ async function showWatchlistModal() {
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download Origins
</button>
<button class="playlist-modal-btn playlist-modal-btn-secondary watchlist-btn-blocklist"
id="watchlist-blocklist-btn"
onclick="openBlocklistModal('artist')"
title="Block artists, albums or tracks from ever being downloaded">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.9" y1="4.9" x2="19.1" y2="19.1"/></svg>
Blocklist
</button>
</div>
${globalOverrideActive ? `

186
webui/static/blocklist.js Normal file
View file

@ -0,0 +1,186 @@
// ── Blocklist modal ──
// A proper artist/album/track blacklist. Search the active metadata source,
// block by ID (cross-source matched in the background so a ban survives a
// source switch), and manage existing bans. Opened from the Watchlist page.
// Distinct from the download-source blacklist.
let _blEntityType = 'artist'; // active tab: artist | album | track
let _blSearchSeq = 0; // guards against out-of-order search results
function openBlocklistModal(initialType) {
_blEntityType = ['artist', 'album', 'track'].includes(initialType) ? initialType : 'artist';
let overlay = document.getElementById('blocklist-modal-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'blocklist-modal-overlay';
overlay.className = 'modal-overlay blocklist-modal-overlay';
overlay.innerHTML = `
<div class="blocklist-modal">
<div class="blocklist-modal-head">
<div>
<h2 class="blocklist-modal-title">Blocklist</h2>
<p class="blocklist-modal-sub">Block an artist, album, or track from ever being downloaded. Matched across all your metadata sources.</p>
</div>
<button class="blocklist-modal-close" onclick="closeBlocklistModal()" aria-label="Close"></button>
</div>
<div class="blocklist-tabs">
<button class="blocklist-tab" data-bl="artist" onclick="switchBlocklistTab('artist')">Artists</button>
<button class="blocklist-tab" data-bl="album" onclick="switchBlocklistTab('album')">Albums</button>
<button class="blocklist-tab" data-bl="track" onclick="switchBlocklistTab('track')">Tracks</button>
</div>
<div class="blocklist-search-row">
<input type="text" id="blocklist-search-input" class="blocklist-search-input"
placeholder="Search to block…" oninput="onBlocklistSearchInput()">
<div class="blocklist-search-spinner" id="blocklist-search-spinner"></div>
</div>
<div class="blocklist-search-results" id="blocklist-search-results"></div>
<div class="blocklist-current-label">Currently blocked</div>
<div class="blocklist-current" id="blocklist-current"></div>
</div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeBlocklistModal(); });
document.body.appendChild(overlay);
}
overlay.classList.remove('hidden');
_blRefreshTabs();
_blLoadCurrent();
const input = document.getElementById('blocklist-search-input');
if (input) { input.value = ''; input.focus(); }
document.getElementById('blocklist-search-results').innerHTML = '';
}
function closeBlocklistModal() {
const o = document.getElementById('blocklist-modal-overlay');
if (o) o.classList.add('hidden');
}
function switchBlocklistTab(type) {
if (type === _blEntityType) return;
_blEntityType = type;
_blRefreshTabs();
const input = document.getElementById('blocklist-search-input');
if (input) input.value = '';
document.getElementById('blocklist-search-results').innerHTML = '';
_blLoadCurrent();
}
function _blRefreshTabs() {
document.querySelectorAll('.blocklist-tab').forEach(b =>
b.classList.toggle('active', b.dataset.bl === _blEntityType));
const input = document.getElementById('blocklist-search-input');
if (input) input.placeholder = `Search ${_blEntityType}s to block…`;
}
let _blSearchTimer = null;
function onBlocklistSearchInput() {
clearTimeout(_blSearchTimer);
_blSearchTimer = setTimeout(_blRunSearch, 300); // debounce
}
async function _blRunSearch() {
const input = document.getElementById('blocklist-search-input');
const box = document.getElementById('blocklist-search-results');
const spinner = document.getElementById('blocklist-search-spinner');
const q = (input.value || '').trim();
if (!q) { box.innerHTML = ''; return; }
const seq = ++_blSearchSeq;
spinner.classList.add('spinning');
try {
const res = await fetch(`/api/blocklist/search?type=${_blEntityType}&q=${encodeURIComponent(q)}`);
const data = await res.json();
if (seq !== _blSearchSeq) return; // a newer search superseded this
if (!data.success) throw new Error(data.error || 'Search failed');
const results = data.results || [];
if (!results.length) {
box.innerHTML = '<div class="blocklist-empty">No matches.</div>';
return;
}
box.innerHTML = results.map(r => {
const img = r.image
? `<img class="blocklist-row-img${_blEntityType === 'artist' ? ' artist' : ''}" src="${escapeHtml(r.image)}" alt="" loading="lazy" onerror="this.style.visibility='hidden'">`
: `<div class="blocklist-row-img${_blEntityType === 'artist' ? ' artist' : ''} placeholder">🎵</div>`;
const payload = encodeURIComponent(JSON.stringify({
name: r.name || 'Unknown', source: r.provider || data.source,
source_id: r.id, parent_name: r.extra || ''
}));
return `<div class="blocklist-row">
${img}
<div class="blocklist-row-info">
<div class="blocklist-row-name">${escapeHtml(r.name || 'Unknown')}</div>
${r.extra ? `<div class="blocklist-row-extra">${escapeHtml(r.extra)}</div>` : ''}
</div>
<button class="blocklist-block-btn" onclick="blockFromSearch('${payload}')">Block</button>
</div>`;
}).join('');
} catch (e) {
if (seq === _blSearchSeq) box.innerHTML = `<div class="blocklist-empty">Couldn't search: ${escapeHtml(e.message)}</div>`;
} finally {
if (seq === _blSearchSeq) spinner.classList.remove('spinning');
}
}
async function blockFromSearch(payloadEnc) {
let p;
try { p = JSON.parse(decodeURIComponent(payloadEnc)); } catch (e) { return; }
try {
const res = await fetch('/api/blocklist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: _blEntityType, ...p }),
});
const data = await res.json();
if (data.success) {
showToast(`Blocked ${_blEntityType}: ${p.name}`, 'success');
const input = document.getElementById('blocklist-search-input');
if (input) input.value = '';
document.getElementById('blocklist-search-results').innerHTML = '';
_blLoadCurrent();
} else {
showToast(data.error || 'Failed to block', 'error');
}
} catch (e) {
showToast('Error blocking item', 'error');
}
}
async function _blLoadCurrent() {
const box = document.getElementById('blocklist-current');
box.innerHTML = '<div class="blocklist-empty">Loading…</div>';
try {
const res = await fetch(`/api/blocklist?entity_type=${_blEntityType}`);
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to load');
const entries = data.entries || [];
if (!entries.length) {
box.innerHTML = `<div class="blocklist-empty">No blocked ${_blEntityType}s yet.</div>`;
return;
}
box.innerHTML = entries.map(e => {
const sources = ['spotify_id', 'itunes_id', 'deezer_id', 'musicbrainz_id']
.filter(k => e[k]).length;
const matchTag = e.match_status === 'matched' || sources >= 2
? `<span class="blocklist-match matched" title="Matched across ${sources} sources">${sources}★</span>`
: `<span class="blocklist-match pending" title="Matching other sources…">●</span>`;
return `<div class="blocklist-current-row">
<div class="blocklist-row-info">
<div class="blocklist-row-name">${escapeHtml(e.name)}</div>
${e.parent_name ? `<div class="blocklist-row-extra">${escapeHtml(e.parent_name)}</div>` : ''}
</div>
${matchTag}
<button class="blocklist-unblock-btn" onclick="unblockEntry(${e.id})" title="Remove"></button>
</div>`;
}).join('');
} catch (e) {
box.innerHTML = `<div class="blocklist-empty">Couldn't load: ${escapeHtml(e.message)}</div>`;
}
}
async function unblockEntry(id) {
try {
const res = await fetch(`/api/blocklist/${id}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) { showToast('Removed from blocklist', 'success'); _blLoadCurrent(); }
else showToast(data.error || 'Failed to remove', 'error');
} catch (e) {
showToast('Error removing entry', 'error');
}
}

View file

@ -66691,3 +66691,117 @@ body.em-scroll-lock { overflow: hidden; }
border-color: rgba(248, 113, 113, 0.4) !important;
color: #f87171 !important;
}
/* ── Blocklist modal (artist/album/track bans) ── */
.blocklist-modal {
position: relative;
width: 640px;
max-width: 94vw;
max-height: 86vh;
display: flex;
flex-direction: column;
background: linear-gradient(165deg, rgba(24,24,32,0.97) 0%, rgba(13,13,18,0.985) 60%, rgba(10,10,14,0.99) 100%);
backdrop-filter: blur(24px) saturate(1.35);
-webkit-backdrop-filter: blur(24px) saturate(1.35);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 24px;
box-shadow: 0 32px 80px rgba(0,0,0,0.65), 0 0 60px rgba(var(--accent-rgb),0.07), inset 0 1px 0 rgba(255,255,255,0.05);
animation: modal-revamp-enter 0.38s cubic-bezier(0.22,1.2,0.36,1);
overflow: hidden;
}
.blocklist-modal::before {
content: '';
position: absolute;
top: 0; left: 8%; right: 8%;
height: 1.5px;
background: linear-gradient(90deg, transparent, rgba(var(--accent-rgb),0.65) 30%, rgba(var(--accent-light-rgb),0.85) 50%, rgba(var(--accent-rgb),0.65) 70%, transparent);
pointer-events: none;
z-index: 3;
}
.blocklist-modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 20px 24px 14px;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.blocklist-modal-title { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: -0.01em; }
.blocklist-modal-sub { margin: 3px 0 0; font-size: 12px; color: rgba(255,255,255,0.4); max-width: 460px; }
.blocklist-modal-close {
width: 32px; height: 32px; border-radius: 50%;
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.04);
color: rgba(255,255,255,0.7); cursor: pointer;
transition: background 0.2s, transform 0.2s, color 0.2s;
}
.blocklist-modal-close:hover { background: rgba(239,68,68,0.18); color: #fff; transform: rotate(90deg); }
.blocklist-tabs { display: flex; gap: 8px; padding: 12px 24px 0; }
.blocklist-tab {
padding: 7px 16px; border-radius: 999px;
border: 1px solid rgba(255,255,255,0.09); background: rgba(255,255,255,0.03);
color: rgba(255,255,255,0.6); font-size: 12.5px; font-weight: 600; cursor: pointer;
transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.blocklist-tab:hover { background: rgba(255,255,255,0.07); }
.blocklist-tab.active {
background: linear-gradient(135deg, rgba(var(--accent-rgb),0.9), rgba(var(--accent-rgb),0.65));
border-color: rgba(var(--accent-light-rgb),0.4); color: #fff;
box-shadow: 0 3px 14px rgba(var(--accent-rgb),0.3);
}
.blocklist-search-row { position: relative; padding: 14px 24px 8px; }
.blocklist-search-input {
width: 100%; padding: 11px 38px 11px 14px; border-radius: 12px;
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.04);
color: #fff; font-size: 13.5px; outline: none;
transition: border-color 0.2s, background 0.2s;
}
.blocklist-search-input:focus { border-color: rgba(var(--accent-rgb),0.5); background: rgba(255,255,255,0.06); }
.blocklist-search-spinner {
position: absolute; right: 36px; top: 50%; width: 15px; height: 15px;
margin-top: -3px; border-radius: 50%; opacity: 0;
border: 2px solid rgba(var(--accent-rgb),0.25); border-top-color: rgb(var(--accent-rgb));
}
.blocklist-search-spinner.spinning { opacity: 1; animation: spin 0.7s linear infinite; }
.blocklist-search-results { max-height: 230px; overflow-y: auto; padding: 0 16px; }
.blocklist-row, .blocklist-current-row {
display: flex; align-items: center; gap: 12px;
padding: 8px 8px; border-radius: 12px;
transition: background 0.15s;
}
.blocklist-row:hover { background: rgba(255,255,255,0.04); }
.blocklist-row-img { width: 42px; height: 42px; border-radius: 8px; object-fit: cover; flex: 0 0 auto; }
.blocklist-row-img.artist { border-radius: 50%; }
.blocklist-row-img.placeholder {
display: flex; align-items: center; justify-content: center;
background: rgba(255,255,255,0.05); font-size: 18px;
}
.blocklist-row-info { flex: 1; min-width: 0; }
.blocklist-row-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.blocklist-row-extra { font-size: 11.5px; color: rgba(255,255,255,0.4); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.blocklist-block-btn {
flex: 0 0 auto; padding: 6px 16px; border-radius: 999px;
border: 1px solid rgba(239,68,68,0.4); background: rgba(239,68,68,0.12);
color: #fca5a5; font-size: 12px; font-weight: 600; cursor: pointer;
transition: background 0.18s, transform 0.18s;
}
.blocklist-block-btn:hover { background: rgba(239,68,68,0.24); transform: translateY(-1px); }
.blocklist-current-label {
padding: 12px 24px 6px; font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em; color: rgba(255,255,255,0.35);
border-top: 1px solid rgba(255,255,255,0.05); margin-top: 6px;
}
.blocklist-current { flex: 1; overflow-y: auto; padding: 0 16px 16px; min-height: 80px; }
.blocklist-current-row:hover { background: rgba(255,255,255,0.03); }
.blocklist-match { flex: 0 0 auto; font-size: 11px; font-weight: 700; }
.blocklist-match.matched { color: rgb(var(--accent-light-rgb)); }
.blocklist-match.pending { color: rgba(255,255,255,0.3); animation: bl-pulse 1.6s ease-in-out infinite; }
@keyframes bl-pulse { 0%,100% { opacity: 0.3; } 50% { opacity: 0.8; } }
.blocklist-unblock-btn {
flex: 0 0 auto; width: 26px; height: 26px; border-radius: 50%;
border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.04);
color: rgba(255,255,255,0.55); cursor: pointer; font-size: 12px;
transition: background 0.18s, color 0.18s;
}
.blocklist-unblock-btn:hover { background: rgba(239,68,68,0.18); color: #fff; }
.blocklist-empty { padding: 26px 16px; text-align: center; color: rgba(255,255,255,0.4); font-size: 12.5px; }
.blocklist-search-results::-webkit-scrollbar, .blocklist-current::-webkit-scrollbar { width: 8px; }
.blocklist-search-results::-webkit-scrollbar-thumb, .blocklist-current::-webkit-scrollbar-thumb { background: rgba(var(--accent-rgb),0.28); border-radius: 999px; }