From 45badf588c0c9e27384d763e93917e059e6b4521 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 7 Jun 2026 15:49:59 -0700 Subject: [PATCH] Blocklist Phase 2a: gate the download queue (playlist sync / album / discography) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- core/downloads/master.py | 23 +++++++++++++++++ database/music_database.py | 18 ++++++++----- tests/blocklist/test_blocklist_db.py | 38 ++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) diff --git a/core/downloads/master.py b/core/downloads/master.py index a3e50083..e85d1c01 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -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 diff --git a/database/music_database.py b/database/music_database.py index ce405011..b2efbb69 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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]) diff --git a/tests/blocklist/test_blocklist_db.py b/tests/blocklist/test_blocklist_db.py index 3447ab13..462d397c 100644 --- a/tests/blocklist/test_blocklist_db.py +++ b/tests/blocklist/test_blocklist_db.py @@ -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"]