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 = ` +
Block an artist, album, or track from ever being downloaded. Matched across all your metadata sources.
+