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:
parent
43c798a76e
commit
b6d78d015d
10 changed files with 642 additions and 1 deletions
50
core/blocklist/backfill.py
Normal file
50
core/blocklist/backfill.py
Normal 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
82
core/blocklist/runtime.py
Normal 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")}
|
||||
48
tests/blocklist/test_backfill.py
Normal file
48
tests/blocklist/test_backfill.py
Normal 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"}
|
||||
61
tests/blocklist/test_blocklist_api.py
Normal file
61
tests/blocklist/test_blocklist_api.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
186
webui/static/blocklist.js
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue