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).
183 lines
7.8 KiB
Python
183 lines
7.8 KiB
Python
"""Blocklist DB layer + the add_to_wishlist enforcement guard.
|
|
|
|
Verified against a real SQLite DB: CRUD + profile scoping + dedup + backfill
|
|
update, the discovery→blocklist migration, and the end-to-end wishlist guard
|
|
(a banned artist's track is refused; cascade; profile isolation).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from database.music_database import MusicDatabase
|
|
|
|
|
|
@pytest.fixture()
|
|
def db(tmp_path):
|
|
return MusicDatabase(str(tmp_path / "m.db"))
|
|
|
|
|
|
def _track(track_id, name, artist_id, artist_name, album_id="al0", album_name="Al", source="spotify"):
|
|
return {
|
|
"id": track_id, "name": name, "source": source,
|
|
"artists": [{"id": artist_id, "name": artist_name}],
|
|
"album": {"id": album_id, "name": album_name, "artists": [{"id": artist_id, "name": artist_name}]},
|
|
}
|
|
|
|
|
|
# ── CRUD + scoping ───────────────────────────────────────────────────────────
|
|
|
|
def test_add_list_remove(db):
|
|
eid = db.add_blocklist_entry(1, "artist", "Drake", spotify_id="drake-sp")
|
|
assert eid
|
|
rows = db.get_blocklist(1)
|
|
assert len(rows) == 1 and rows[0]["name"] == "Drake"
|
|
assert db.remove_blocklist_entry(1, eid) is True
|
|
assert db.get_blocklist(1) == []
|
|
|
|
|
|
def test_dedup_by_id_and_name(db):
|
|
a = db.add_blocklist_entry(1, "artist", "Drake", spotify_id="sp")
|
|
b = db.add_blocklist_entry(1, "artist", "Drake", spotify_id="sp") # same id
|
|
c = db.add_blocklist_entry(1, "artist", "drake") # same name (NOCASE)
|
|
assert a == b == c
|
|
assert len(db.get_blocklist(1)) == 1
|
|
|
|
|
|
def test_profile_isolation(db):
|
|
db.add_blocklist_entry(1, "artist", "Drake", spotify_id="sp")
|
|
assert len(db.get_blocklist(1)) == 1
|
|
assert db.get_blocklist(2) == []
|
|
# remove is profile-scoped — profile 2 can't delete profile 1's row
|
|
eid = db.get_blocklist(1)[0]["id"]
|
|
assert db.remove_blocklist_entry(2, eid) is False
|
|
assert len(db.get_blocklist(1)) == 1
|
|
|
|
|
|
def test_match_status_pending_vs_matched(db):
|
|
one = db.add_blocklist_entry(1, "artist", "A", spotify_id="sp")
|
|
two = db.add_blocklist_entry(1, "artist", "B", spotify_id="sp2", deezer_id="dz2")
|
|
rows = {r["id"]: r for r in db.get_blocklist(1)}
|
|
assert rows[one]["match_status"] == "pending" # single id → needs backfill
|
|
assert rows[two]["match_status"] == "matched" # 2+ ids → already cross-known
|
|
|
|
|
|
def test_backfill_update_fills_only_nulls(db):
|
|
eid = db.add_blocklist_entry(1, "artist", "A", spotify_id="sp")
|
|
db.update_blocklist_entry_ids(eid, deezer_id="dz", spotify_id="SHOULD-NOT-OVERWRITE")
|
|
row = db.get_blocklist(1)[0]
|
|
assert row["spotify_id"] == "sp" # COALESCE: existing kept
|
|
assert row["deezer_id"] == "dz"
|
|
assert row["match_status"] == "matched"
|
|
assert db.get_blocklist_entries_needing_backfill() == []
|
|
|
|
|
|
# ── the wishlist guard, end to end ───────────────────────────────────────────
|
|
|
|
def test_blocked_artist_track_refused_from_wishlist(db):
|
|
db.add_blocklist_entry(1, "artist", "Drake", spotify_id="drake-sp")
|
|
ok = db.add_to_wishlist(
|
|
spotify_track_data=_track("t1", "God's Plan", "drake-sp", "Drake"),
|
|
profile_id=1)
|
|
assert ok is False # refused
|
|
# And nothing landed in the wishlist.
|
|
assert db.get_wishlist_count(profile_id=1) == 0
|
|
|
|
|
|
def test_unblocked_artist_track_is_added(db):
|
|
db.add_blocklist_entry(1, "artist", "Drake", spotify_id="drake-sp")
|
|
ok = db.add_to_wishlist(
|
|
spotify_track_data=_track("t2", "Hello", "adele-sp", "Adele"),
|
|
profile_id=1)
|
|
assert ok is True
|
|
assert db.get_wishlist_count(profile_id=1) == 1
|
|
|
|
|
|
def test_block_is_profile_scoped_at_the_guard(db):
|
|
db.add_blocklist_entry(1, "artist", "Drake", spotify_id="drake-sp")
|
|
# Profile 2 has no such ban → the same track is allowed.
|
|
ok = db.add_to_wishlist(
|
|
spotify_track_data=_track("t3", "Nice For What", "drake-sp", "Drake"),
|
|
profile_id=2)
|
|
assert ok is True
|
|
|
|
|
|
def test_album_block_cascades_to_track_at_guard(db):
|
|
db.add_blocklist_entry(1, "album", "Scorpion", spotify_id="scorp-sp")
|
|
ok = db.add_to_wishlist(
|
|
spotify_track_data=_track("t4", "Nonstop", "drake-sp", "Drake",
|
|
album_id="scorp-sp", album_name="Scorpion"),
|
|
profile_id=1)
|
|
assert ok is False
|
|
|
|
|
|
def test_discovery_blacklist_migrated_into_blocklist(tmp_path):
|
|
import database.music_database as mdb
|
|
|
|
def _reinit(path):
|
|
# An app upgrade = a fresh process with an empty init memo. Clear the
|
|
# per-process "already initialized" set so init (and the migration)
|
|
# actually re-runs against the existing DB file.
|
|
mdb._database_initialized_paths.discard(str(mdb.Path(path).resolve()))
|
|
return MusicDatabase(path)
|
|
|
|
path = str(tmp_path / "mig.db")
|
|
db = MusicDatabase(path)
|
|
conn = db._get_connection()
|
|
cur = conn.cursor()
|
|
cur.execute("INSERT INTO discovery_artist_blacklist (artist_name, spotify_artist_id) "
|
|
"VALUES ('Nickelback', 'nb-sp')")
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
db2 = _reinit(path) # upgrade → migration runs (committed)
|
|
rows = db2.get_blocklist(1)
|
|
assert any(r["name"] == "Nickelback" and r["entity_type"] == "artist" for r in rows)
|
|
|
|
db3 = _reinit(path) # idempotent — a second upgrade doesn't duplicate
|
|
nb = [r for r in db3.get_blocklist(1) if r["name"] == "Nickelback"]
|
|
assert len(nb) == 1
|
|
|
|
# The migrated ban actually enforces at the wishlist guard.
|
|
ok = db3.add_to_wishlist(
|
|
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"]
|