Blocklist Phase 2a: gate the download queue (playlist sync / album / discography)

Phase 1 guarded the wishlist; Phase 2a closes the other auto-acquisition path.
Playlist sync, album download, and discography backfill all flow through
run_full_missing_tracks_process, which queues missing tracks at one point —
right where the explicit-content filter already drops tracks. The blocklist
filter slots in beside it: each missing track is checked and a banned
artist/album/track is dropped before queueing (logged with a count), so a
blocked item can't slip in via these flows.

Same brain as Phase 1: the wishlist guard's matcher is generalized to
db.blocklist_reason_for_track(profile_id, track_data, source=None) — the new
`source` param lets the queue path supply the batch source, since an analysis
track dict may not carry a 'provider' field (artists still match by name
fallback regardless). One method, two callers (wishlist + queue), one cascade.

Manual single-track downloads (/api/download, candidate picker, redownload)
are deliberately NOT gated here — that's Phase 2b, pending a block-vs-warn-vs-
override policy decision.

Tests: source-fallback isolation (album id-only proves source drives the ID
match; artist name still matches sourceless), and a queue-filter simulation
mirroring master.py. 35 blocklist tests pass (the only failures in the
download family are the pre-existing soundcloud /app ones).
This commit is contained in:
BoulderBadgeDad 2026-06-07 15:49:59 -07:00
parent b6d78d015d
commit 45badf588c
3 changed files with 73 additions and 6 deletions

View file

@ -608,6 +608,29 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
if skipped > 0:
logger.warning(f"[Content Filter] Filtered out {skipped} explicit track(s) from download queue")
# Blocklist (Phase 2a): drop banned artists/albums/tracks before queueing,
# so a blocked item can't slip in via playlist sync / album download /
# discography. Same ID-cascade brain as the wishlist guard (Phase 1) —
# the only other auto-acquisition path. (Manual single-track downloads
# are Phase 2b, intentionally not gated here.)
try:
_bl_before = len(missing_tracks)
_bl_kept = []
for res in missing_tracks:
reason = db.blocklist_reason_for_track(
batch_profile_id, res.get('track', {}), source=batch_source)
if reason:
logger.info("[Blocklist] Skipping %s '%s' from download queue (%s blocked)",
reason[0], res.get('track', {}).get('name', '?'), reason[0])
else:
_bl_kept.append(res)
if len(_bl_kept) != _bl_before:
logger.info("[Blocklist] Filtered out %d blocklisted track(s) from download queue",
_bl_before - len(_bl_kept))
missing_tracks = _bl_kept
except Exception as _bl_err:
logger.debug("blocklist queue filter skipped: %s", _bl_err)
with tasks_lock:
if batch_id in download_batches:
download_batches[batch_id]['analysis_results'] = analysis_results

View file

@ -8100,10 +8100,15 @@ class MusicDatabase:
# Wishlist management methods
def _wishlist_blocklist_reason(self, profile_id, track_data):
"""Return (entity_type, label) if this wishlist candidate is blocklisted,
else None. Pure matching lives in core.blocklist; this just pulls the
candidate's active-source IDs out of the payload and asks."""
def blocklist_reason_for_track(self, profile_id, track_data, source=None):
"""Return (entity_type, label) if this track is blocklisted for the
profile, else None. Shared by the wishlist guard (Phase 1) and the
download-queue guard (Phase 2a). Pure matching lives in core.blocklist;
this pulls the candidate's source IDs out of the payload and asks.
``source`` overrides/falls back to the payload's provider — the
download-queue path knows the batch source even when the track dict
doesn't carry a 'provider' field."""
try:
from core.blocklist import build_index, candidate_block_reason
rows = self.get_blocklist_rows_for_matching(profile_id)
@ -8113,7 +8118,8 @@ class MusicDatabase:
if index.is_empty:
return None
td = track_data or {}
source = (td.get('provider') or td.get('source') or '').strip().lower() or None
source = ((td.get('provider') or td.get('source') or source or '')
.strip().lower() or None)
album = td.get('album') if isinstance(td.get('album'), dict) else {}
# Normalise artists to [{'id','name'}] from track + album credits.
artists = []
@ -8162,7 +8168,7 @@ class MusicDatabase:
# Blocklist guard (Phase 1): every auto-acquisition path funnels
# through here, so one check blocks a banned artist/album/track
# (with artist→album→track cascade) before it can be queued.
_blocked = self._wishlist_blocklist_reason(profile_id, spotify_track_data)
_blocked = self.blocklist_reason_for_track(profile_id, spotify_track_data)
if _blocked:
logger.info("Skipping wishlist add — %s is blocklisted: '%s'",
_blocked[0], _blocked[1])

View file

@ -143,3 +143,41 @@ def test_discovery_blacklist_migrated_into_blocklist(tmp_path):
spotify_track_data=_track("t5", "Photograph", "nb-sp", "Nickelback"),
profile_id=1)
assert ok is False
# ── Phase 2a: shared guard with source fallback (download-queue path) ────────
def test_blocklist_reason_source_fallback(db):
"""The download-queue guard passes the batch source explicitly because the
analysis track dict may not carry a 'provider' field. Uses an ALBUM ban
(id-only, no name fallback) to isolate the source-driven ID match."""
db.add_blocklist_entry(1, "album", "Scorpion", deezer_id="scorp-dz")
track = {"id": "t1", "name": "Nonstop",
"artists": [{"id": "drake", "name": "Drake"}],
"album": {"id": "scorp-dz", "name": "Scorpion"}}
assert db.blocklist_reason_for_track(1, track) is None # no source → album id can't match
assert db.blocklist_reason_for_track(1, track, source="spotify") is None # wrong source
assert db.blocklist_reason_for_track(1, track, source="deezer") # right source → match
def test_artist_name_fallback_works_without_source(db):
# Artists DO fall back to name, so a ban matches even when the source is
# unknown (covers the cross-source backfill window).
db.add_blocklist_entry(1, "artist", "Drake", deezer_id="drake-dz")
track = {"id": "t1", "name": "Track", "artists": [{"id": "x", "name": "Drake"}],
"album": {"id": "al", "name": "Al"}}
assert db.blocklist_reason_for_track(1, track) is not None
def test_blocklist_reason_simulates_queue_filter(db):
"""Mirror what master.py does: filter a missing-tracks list by the guard."""
db.add_blocklist_entry(1, "artist", "Blocked Guy", spotify_id="bg-sp")
missing = [
{"track": {"id": "t1", "name": "Keep Me",
"artists": [{"id": "ok", "name": "Good Artist"}], "album": {"id": "a1", "name": "A"}}},
{"track": {"id": "t2", "name": "Drop Me",
"artists": [{"id": "bg-sp", "name": "Blocked Guy"}], "album": {"id": "a2", "name": "B"}}},
]
kept = [r for r in missing
if not db.blocklist_reason_for_track(1, r["track"], source="spotify")]
assert [r["track"]["id"] for r in kept] == ["t1"]