feat: auto-discover similar tracks during autoplay via Last.fm/MusicBrainz
Every Nth autoplay pick (configurable via discover_ratio), query Last.fm for similar tracks. When Last.fm has no key or returns nothing, fall back to MusicBrainz tag-based recording search (no API key needed). Discovered tracks are resolved via yt-dlp and deduplicated within the session. If discovery fails, the kept-deck shuffle continues as before. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2362,3 +2362,245 @@ class TestKeptRepair:
|
||||
stored = json.loads(raw)
|
||||
assert stored["filename"] == "song.webm"
|
||||
assert (music_dir / "song.webm").is_file()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestAutoplayDiscovery
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoplayDiscovery:
|
||||
"""Tests for the discovery integration in _play_loop autoplay."""
|
||||
|
||||
def test_config_defaults(self):
|
||||
"""Default discover/discover_ratio values are set."""
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
assert ps["discover"] is True
|
||||
assert ps["discover_ratio"] == 3
|
||||
|
||||
def test_config_from_toml(self):
|
||||
"""Config values are read from bot config."""
|
||||
bot = _FakeBot()
|
||||
bot.config = {"music": {"discover": False, "discover_ratio": 5}}
|
||||
# Reset pstate so _ps re-reads config
|
||||
bot._pstate.clear()
|
||||
ps = _mod._ps(bot)
|
||||
assert ps["discover"] is False
|
||||
assert ps["discover_ratio"] == 5
|
||||
|
||||
def test_discovery_triggers_on_ratio(self, tmp_path):
|
||||
"""Discovery is attempted when autoplay_count is a multiple of ratio."""
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["autoplay"] = True
|
||||
ps["discover"] = True
|
||||
ps["discover_ratio"] = 1 # trigger every pick
|
||||
ps["autoplay_cooldown"] = 0
|
||||
ps["duck_silence"] = 0
|
||||
|
||||
# Seed history so discovery has something to reference
|
||||
ps["history"] = [
|
||||
_mod._Track(url="x", title="Tool - Lateralus", requester="a"),
|
||||
]
|
||||
|
||||
# Set up kept tracks for fallback pool
|
||||
music_dir = tmp_path / "music"
|
||||
music_dir.mkdir()
|
||||
(music_dir / "a.opus").write_bytes(b"audio")
|
||||
bot.state.set("music", "keep:1", json.dumps({
|
||||
"url": "https://example.com/a", "title": "Kept Track",
|
||||
"filename": "a.opus", "id": 1,
|
||||
}))
|
||||
|
||||
discover_called = []
|
||||
|
||||
async def fake_discover(b, title):
|
||||
discover_called.append(title)
|
||||
return ("Deftones", "Change")
|
||||
|
||||
lastfm_mod = MagicMock()
|
||||
lastfm_mod.discover_similar = fake_discover
|
||||
bot.registry._modules = {"lastfm": lastfm_mod}
|
||||
|
||||
resolved = [("https://youtube.com/watch?v=x", "Deftones - Change")]
|
||||
|
||||
async def _run():
|
||||
with patch.object(_mod, "_MUSIC_DIR", music_dir), \
|
||||
patch.object(_mod, "_resolve_tracks", return_value=resolved), \
|
||||
patch.object(_mod, "_download_track", return_value=None):
|
||||
task = asyncio.create_task(
|
||||
_mod._play_loop(bot, seek=0.0, fade_in=False),
|
||||
)
|
||||
# Let it pick a track, then cancel
|
||||
await asyncio.sleep(0.5)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
asyncio.run(_run())
|
||||
assert len(discover_called) >= 1
|
||||
assert discover_called[0] == "Tool - Lateralus"
|
||||
|
||||
def test_discovery_disabled(self, tmp_path):
|
||||
"""Discovery is skipped when discover=False."""
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["autoplay"] = True
|
||||
ps["discover"] = False
|
||||
ps["discover_ratio"] = 1
|
||||
ps["autoplay_cooldown"] = 0
|
||||
ps["duck_silence"] = 0
|
||||
ps["history"] = [
|
||||
_mod._Track(url="x", title="Tool - Lateralus", requester="a"),
|
||||
]
|
||||
|
||||
music_dir = tmp_path / "music"
|
||||
music_dir.mkdir()
|
||||
(music_dir / "a.opus").write_bytes(b"audio")
|
||||
bot.state.set("music", "keep:1", json.dumps({
|
||||
"url": "https://example.com/a", "title": "Kept Track",
|
||||
"filename": "a.opus", "id": 1,
|
||||
}))
|
||||
|
||||
discover_called = []
|
||||
|
||||
async def fake_discover(b, title):
|
||||
discover_called.append(title)
|
||||
return ("X", "Y")
|
||||
|
||||
lastfm_mod = MagicMock()
|
||||
lastfm_mod.discover_similar = fake_discover
|
||||
bot.registry._modules = {"lastfm": lastfm_mod}
|
||||
|
||||
async def _run():
|
||||
with patch.object(_mod, "_MUSIC_DIR", music_dir), \
|
||||
patch.object(_mod, "_download_track", return_value=None):
|
||||
task = asyncio.create_task(
|
||||
_mod._play_loop(bot, seek=0.0, fade_in=False),
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
asyncio.run(_run())
|
||||
assert discover_called == []
|
||||
|
||||
def test_discovery_dedup(self):
|
||||
"""Same discovered track is not resolved twice (dedup by seen set)."""
|
||||
# Unit-test the dedup logic directly: simulate the set-based
|
||||
# deduplication that _play_loop uses with _discover_seen.
|
||||
_discover_seen: set[str] = set()
|
||||
|
||||
def _would_resolve(artist: str, title: str) -> bool:
|
||||
key = f"{artist.lower()}:{title.lower()}"
|
||||
if key in _discover_seen:
|
||||
return False
|
||||
_discover_seen.add(key)
|
||||
return True
|
||||
|
||||
assert _would_resolve("Deftones", "Change") is True
|
||||
assert _would_resolve("Deftones", "Change") is False
|
||||
assert _would_resolve("deftones", "change") is False
|
||||
assert _would_resolve("Tool", "Sober") is True
|
||||
|
||||
def test_discovery_fallback_to_kept(self, tmp_path):
|
||||
"""Falls back to kept deck when discovery returns None."""
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["autoplay"] = True
|
||||
ps["discover"] = True
|
||||
ps["discover_ratio"] = 1
|
||||
ps["autoplay_cooldown"] = 0
|
||||
ps["duck_silence"] = 0
|
||||
ps["history"] = [
|
||||
_mod._Track(url="x", title="Tool - Lateralus", requester="a"),
|
||||
]
|
||||
|
||||
music_dir = tmp_path / "music"
|
||||
music_dir.mkdir()
|
||||
(music_dir / "a.opus").write_bytes(b"audio")
|
||||
bot.state.set("music", "keep:1", json.dumps({
|
||||
"url": "https://example.com/a", "title": "Kept Track",
|
||||
"filename": "a.opus", "id": 1,
|
||||
}))
|
||||
|
||||
async def fake_discover(b, title):
|
||||
return None
|
||||
|
||||
lastfm_mod = MagicMock()
|
||||
lastfm_mod.discover_similar = fake_discover
|
||||
bot.registry._modules = {"lastfm": lastfm_mod}
|
||||
|
||||
queued_titles = []
|
||||
|
||||
async def _run():
|
||||
with patch.object(_mod, "_MUSIC_DIR", music_dir), \
|
||||
patch.object(_mod, "_download_track", return_value=None):
|
||||
task = asyncio.create_task(
|
||||
_mod._play_loop(bot, seek=0.0, fade_in=False),
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
# Check what was queued -- should be kept track, not discovered
|
||||
if ps.get("current"):
|
||||
queued_titles.append(ps["current"].title)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
asyncio.run(_run())
|
||||
# The kept track should have been used as fallback
|
||||
if queued_titles:
|
||||
assert queued_titles[0] == "Kept Track"
|
||||
|
||||
def test_no_history_skips_discovery(self, tmp_path):
|
||||
"""Discovery is skipped when history is empty."""
|
||||
bot = _FakeBot()
|
||||
ps = _mod._ps(bot)
|
||||
ps["autoplay"] = True
|
||||
ps["discover"] = True
|
||||
ps["discover_ratio"] = 1
|
||||
ps["autoplay_cooldown"] = 0
|
||||
ps["duck_silence"] = 0
|
||||
ps["history"] = []
|
||||
|
||||
music_dir = tmp_path / "music"
|
||||
music_dir.mkdir()
|
||||
(music_dir / "a.opus").write_bytes(b"audio")
|
||||
bot.state.set("music", "keep:1", json.dumps({
|
||||
"url": "https://example.com/a", "title": "Kept Track",
|
||||
"filename": "a.opus", "id": 1,
|
||||
}))
|
||||
|
||||
discover_called = []
|
||||
|
||||
async def fake_discover(b, title):
|
||||
discover_called.append(title)
|
||||
return ("X", "Y")
|
||||
|
||||
lastfm_mod = MagicMock()
|
||||
lastfm_mod.discover_similar = fake_discover
|
||||
bot.registry._modules = {"lastfm": lastfm_mod}
|
||||
|
||||
async def _run():
|
||||
with patch.object(_mod, "_MUSIC_DIR", music_dir), \
|
||||
patch.object(_mod, "_download_track", return_value=None):
|
||||
task = asyncio.create_task(
|
||||
_mod._play_loop(bot, seek=0.0, fade_in=False),
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
asyncio.run(_run())
|
||||
assert discover_called == []
|
||||
|
||||
Reference in New Issue
Block a user