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.
48 lines
1.8 KiB
Python
48 lines
1.8 KiB
Python
"""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"}
|