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:
|
if skipped > 0:
|
||||||
logger.warning(f"[Content Filter] Filtered out {skipped} explicit track(s) from download queue")
|
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:
|
with tasks_lock:
|
||||||
if batch_id in download_batches:
|
if batch_id in download_batches:
|
||||||
download_batches[batch_id]['analysis_results'] = analysis_results
|
download_batches[batch_id]['analysis_results'] = analysis_results
|
||||||
|
|
|
||||||
|
|
@ -8100,10 +8100,15 @@ class MusicDatabase:
|
||||||
|
|
||||||
# Wishlist management methods
|
# Wishlist management methods
|
||||||
|
|
||||||
def _wishlist_blocklist_reason(self, profile_id, track_data):
|
def blocklist_reason_for_track(self, profile_id, track_data, source=None):
|
||||||
"""Return (entity_type, label) if this wishlist candidate is blocklisted,
|
"""Return (entity_type, label) if this track is blocklisted for the
|
||||||
else None. Pure matching lives in core.blocklist; this just pulls the
|
profile, else None. Shared by the wishlist guard (Phase 1) and the
|
||||||
candidate's active-source IDs out of the payload and asks."""
|
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:
|
try:
|
||||||
from core.blocklist import build_index, candidate_block_reason
|
from core.blocklist import build_index, candidate_block_reason
|
||||||
rows = self.get_blocklist_rows_for_matching(profile_id)
|
rows = self.get_blocklist_rows_for_matching(profile_id)
|
||||||
|
|
@ -8113,7 +8118,8 @@ class MusicDatabase:
|
||||||
if index.is_empty:
|
if index.is_empty:
|
||||||
return None
|
return None
|
||||||
td = track_data or {}
|
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 {}
|
album = td.get('album') if isinstance(td.get('album'), dict) else {}
|
||||||
# Normalise artists to [{'id','name'}] from track + album credits.
|
# Normalise artists to [{'id','name'}] from track + album credits.
|
||||||
artists = []
|
artists = []
|
||||||
|
|
@ -8162,7 +8168,7 @@ class MusicDatabase:
|
||||||
# Blocklist guard (Phase 1): every auto-acquisition path funnels
|
# Blocklist guard (Phase 1): every auto-acquisition path funnels
|
||||||
# through here, so one check blocks a banned artist/album/track
|
# through here, so one check blocks a banned artist/album/track
|
||||||
# (with artist→album→track cascade) before it can be queued.
|
# (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:
|
if _blocked:
|
||||||
logger.info("Skipping wishlist add — %s is blocklisted: '%s'",
|
logger.info("Skipping wishlist add — %s is blocklisted: '%s'",
|
||||||
_blocked[0], _blocked[1])
|
_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"),
|
spotify_track_data=_track("t5", "Photograph", "nb-sp", "Nickelback"),
|
||||||
profile_id=1)
|
profile_id=1)
|
||||||
assert ok is False
|
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