A proper artist/album/track blacklist (distinct from download_blacklist, which stays untouched). ID-keyed across metadata sources so a ban survives a source switch; profile-scoped; cascade artist→album→track. - core/blocklist/matching.py — pure decision core (no I/O): build an index from rows, candidate_block_reason() walks track→album→artist. Same-source ID match is primary; artist NAME is a fallback (covers the ID-backfill window); albums/tracks are ID-only (common titles like "Greatest Hits" must not false-positive across artists). Source-isolated so a numeric Deezer id can't collide with a numeric iTunes id of a different entity. - DB: new `blocklist` table (profile_id, entity_type, name, 4 source-id cols, match_status) + CRUD, match-row fetch, backfill-pending query, id-backfill update (COALESCE — fills NULLs only). - Guard: _wishlist_blocklist_reason at the top of add_to_wishlist — every auto-acquisition path funnels through it, so one check covers watchlist, discography backfill, repair, manual add. Fails OPEN (a guard error never blocks a legitimate add). - Discovery unified IN: legacy discovery_artist_blacklist is migrated into the blocklist on upgrade (replicated to every profile so no global ban silently stops working; idempotent; legacy table kept for rollback). Discovery reads (hero + personalized-playlist SQL) now union the blocklist, so a new-modal ban filters discovery too. Tests: 13 on the pure matcher (cascade, id-vs-name rules, source isolation, precedence) + 10 on the DB/guard (CRUD, profile isolation, dedup, backfill, end-to-end wishlist refusal + cascade + the discovery migration upgrade path). 50 blocklist/personalized tests pass.
128 lines
4.9 KiB
Python
128 lines
4.9 KiB
Python
"""Pure blocklist matching — no DB, no I/O, fully unit-testable.
|
|
|
|
The brain of the blocklist: given the stored blocklist rows and a candidate
|
|
track being considered for the wishlist, decide whether it's blocked.
|
|
|
|
Design decisions (per Boulder):
|
|
- **ID-keyed.** Each row carries the candidate's IDs in up to four metadata
|
|
sources. A candidate is matched against the SAME source it came in on
|
|
(the wishlist payload carries active-source IDs), so a Deezer-numeric id
|
|
can't collide with an iTunes-numeric id of a different entity.
|
|
- **Cascade.** Blocking an artist blocks their albums + tracks; blocking an
|
|
album blocks its tracks. The candidate carries its own artist/album/track
|
|
IDs, so the check walks track → album → artist and blocks on the first hit.
|
|
- **Name fallback for ARTISTS only.** A blocked artist also matches by
|
|
case-folded name — this covers the window before the background ID-backfill
|
|
has resolved the active source's id. Albums/tracks do NOT fall back to name
|
|
(common titles like "Greatest Hits" would false-positive across artists);
|
|
they rely on IDs, which backfill fills in.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
|
|
|
ENTITY_ARTIST = "artist"
|
|
ENTITY_ALBUM = "album"
|
|
ENTITY_TRACK = "track"
|
|
ENTITY_TYPES = (ENTITY_ARTIST, ENTITY_ALBUM, ENTITY_TRACK)
|
|
|
|
# Blocklist-row column → the metadata source it belongs to.
|
|
SOURCE_ID_FIELDS = {
|
|
"spotify": "spotify_id",
|
|
"itunes": "itunes_id",
|
|
"deezer": "deezer_id",
|
|
"musicbrainz": "musicbrainz_id",
|
|
}
|
|
|
|
|
|
def _norm(text: Any) -> str:
|
|
return str(text or "").strip().casefold()
|
|
|
|
|
|
@dataclass
|
|
class _TypeIndex:
|
|
# per-source set of blocked ids, plus a case-folded name set (artists only)
|
|
ids: Dict[str, Set[str]] = field(default_factory=lambda: {s: set() for s in SOURCE_ID_FIELDS})
|
|
names: Set[str] = field(default_factory=set)
|
|
|
|
def hit(self, source: Optional[str], entity_id: Any, name: Any, use_name: bool) -> bool:
|
|
if entity_id and source in self.ids and str(entity_id) in self.ids[source]:
|
|
return True
|
|
if use_name and name and _norm(name) in self.names:
|
|
return True
|
|
return False
|
|
|
|
|
|
@dataclass
|
|
class BlocklistIndex:
|
|
"""Membership index built once per scan from the blocklist rows."""
|
|
artists: _TypeIndex = field(default_factory=_TypeIndex)
|
|
albums: _TypeIndex = field(default_factory=_TypeIndex)
|
|
tracks: _TypeIndex = field(default_factory=_TypeIndex)
|
|
|
|
@property
|
|
def is_empty(self) -> bool:
|
|
for ti in (self.artists, self.albums, self.tracks):
|
|
if ti.names or any(ti.ids.values()):
|
|
return False
|
|
return True
|
|
|
|
|
|
def build_index(rows: Iterable[Dict[str, Any]]) -> BlocklistIndex:
|
|
"""Build a BlocklistIndex from blocklist DB rows.
|
|
|
|
Each row needs ``entity_type``, ``name``, and the source id columns
|
|
(``spotify_id`` / ``itunes_id`` / ``deezer_id`` / ``musicbrainz_id``).
|
|
Unknown entity types are ignored."""
|
|
idx = BlocklistIndex()
|
|
by_type = {ENTITY_ARTIST: idx.artists, ENTITY_ALBUM: idx.albums, ENTITY_TRACK: idx.tracks}
|
|
for row in rows or []:
|
|
ti = by_type.get((row.get("entity_type") or "").strip().lower())
|
|
if ti is None:
|
|
continue
|
|
for source, col in SOURCE_ID_FIELDS.items():
|
|
val = row.get(col)
|
|
if val:
|
|
ti.ids[source].add(str(val))
|
|
name = row.get("name")
|
|
if name:
|
|
ti.names.add(_norm(name))
|
|
return idx
|
|
|
|
|
|
def candidate_block_reason(
|
|
index: BlocklistIndex,
|
|
*,
|
|
source: Optional[str],
|
|
track_id: Any = None,
|
|
track_name: Any = None,
|
|
album_id: Any = None,
|
|
album_name: Any = None,
|
|
artists: Optional[List[Dict[str, Any]]] = None,
|
|
) -> Optional[Tuple[str, str]]:
|
|
"""Return ``(entity_type, label)`` for the first cascade hit, else None.
|
|
|
|
``source`` is the metadata source the candidate IDs came from (the wishlist
|
|
payload's provider). ``artists`` is a list of ``{'id', 'name'}`` dicts.
|
|
Order matters only for the returned reason — any hit blocks."""
|
|
if index.is_empty:
|
|
return None
|
|
|
|
# Track level — id only (names too ambiguous to ban across artists).
|
|
if index.tracks.hit(source, track_id, track_name, use_name=False):
|
|
return (ENTITY_TRACK, str(track_name or track_id or "track"))
|
|
|
|
# Album level — id only.
|
|
if index.albums.hit(source, album_id, album_name, use_name=False):
|
|
return (ENTITY_ALBUM, str(album_name or album_id or "album"))
|
|
|
|
# Artist level — id OR case-folded name (safe + covers the backfill window).
|
|
for artist in artists or []:
|
|
a_id = artist.get("id") if isinstance(artist, dict) else None
|
|
a_name = artist.get("name") if isinstance(artist, dict) else artist
|
|
if index.artists.hit(source, a_id, a_name, use_name=True):
|
|
return (ENTITY_ARTIST, str(a_name or a_id or "artist"))
|
|
|
|
return None
|