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:
parent
b6d78d015d
commit
45badf588c
3 changed files with 73 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in a new issue