diff --git a/core/blocklist/backfill.py b/core/blocklist/backfill.py new file mode 100644 index 00000000..1cf0c744 --- /dev/null +++ b/core/blocklist/backfill.py @@ -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 diff --git a/core/blocklist/runtime.py b/core/blocklist/runtime.py new file mode 100644 index 00000000..6d107d81 --- /dev/null +++ b/core/blocklist/runtime.py @@ -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")} diff --git a/tests/blocklist/test_backfill.py b/tests/blocklist/test_backfill.py new file mode 100644 index 00000000..e222ba4e --- /dev/null +++ b/tests/blocklist/test_backfill.py @@ -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"} diff --git a/tests/blocklist/test_blocklist_api.py b/tests/blocklist/test_blocklist_api.py new file mode 100644 index 00000000..b9ad12e9 --- /dev/null +++ b/tests/blocklist/test_blocklist_api.py @@ -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 diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py index 876f4d58..4390f98a 100644 --- a/tests/test_script_split_integrity.py +++ b/tests/test_script_split_integrity.py @@ -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 + diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index cd39fbd1..0d51d85b 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -2047,6 +2047,13 @@ async function showWatchlistModal() { Download Origins + ${globalOverrideActive ? ` diff --git a/webui/static/blocklist.js b/webui/static/blocklist.js new file mode 100644 index 00000000..dbc0410a --- /dev/null +++ b/webui/static/blocklist.js @@ -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 = ` +
+
+
+

Blocklist

+

Block an artist, album, or track from ever being downloaded. Matched across all your metadata sources.

+
+ +
+
+ + + +
+
+ +
+
+
+
Currently blocked
+
+
`; + 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 = '
No matches.
'; + return; + } + box.innerHTML = results.map(r => { + const img = r.image + ? `` + : `
🎵
`; + const payload = encodeURIComponent(JSON.stringify({ + name: r.name || 'Unknown', source: r.provider || data.source, + source_id: r.id, parent_name: r.extra || '' + })); + return `
+ ${img} +
+
${escapeHtml(r.name || 'Unknown')}
+ ${r.extra ? `
${escapeHtml(r.extra)}
` : ''} +
+ +
`; + }).join(''); + } catch (e) { + if (seq === _blSearchSeq) box.innerHTML = `
Couldn't search: ${escapeHtml(e.message)}
`; + } 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 = '
Loading…
'; + 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 = `
No blocked ${_blEntityType}s yet.
`; + 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 + ? `${sources}★` + : ``; + return `
+
+
${escapeHtml(e.name)}
+ ${e.parent_name ? `
${escapeHtml(e.parent_name)}
` : ''} +
+ ${matchTag} + +
`; + }).join(''); + } catch (e) { + box.innerHTML = `
Couldn't load: ${escapeHtml(e.message)}
`; + } +} + +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'); + } +} diff --git a/webui/static/style.css b/webui/static/style.css index b236beff..0c2900d9 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; }