feat: rework !similar to build and play discovery playlists
Default !similar now discovers similar artists/tracks, resolves each against YouTube in parallel via ThreadPoolExecutor, fades out current playback, and starts the new playlist. Old display behavior moves to !similar list subcommand. New helpers: _search_queries() normalizes Last.fm/MB results into search strings, _resolve_playlist() resolves queries to _Track objects in parallel. Falls back to display mode when music plugin not loaded. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -363,11 +363,54 @@ class TestFmtMatch:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSearchQueries:
|
||||
def test_track_results(self):
|
||||
similar = [
|
||||
{"name": "Track X", "artist": {"name": "Band A"}, "match": "0.9"},
|
||||
{"name": "Track Y", "artist": {"name": "Band B"}, "match": "0.7"},
|
||||
]
|
||||
result = _mod._search_queries(similar, [], [])
|
||||
assert result == ["Band A Track X", "Band B Track Y"]
|
||||
|
||||
def test_artist_results(self):
|
||||
artists = [{"name": "Deftones"}, {"name": "APC"}]
|
||||
result = _mod._search_queries([], artists, [])
|
||||
assert result == ["Deftones", "APC"]
|
||||
|
||||
def test_mb_results(self):
|
||||
mb = [{"artist": "MB Band", "title": "MB Song"}]
|
||||
result = _mod._search_queries([], [], mb)
|
||||
assert result == ["MB Band MB Song"]
|
||||
|
||||
def test_mixed_sources(self):
|
||||
"""Track results come first, then artist, then MB."""
|
||||
similar = [{"name": "T1", "artist": {"name": "A1"}}]
|
||||
artists = [{"name": "A2"}]
|
||||
mb = [{"artist": "MB", "title": "S1"}]
|
||||
result = _mod._search_queries(similar, artists, mb)
|
||||
assert result == ["A1 T1", "A2", "MB S1"]
|
||||
|
||||
def test_limit(self):
|
||||
artists = [{"name": f"Band {i}"} for i in range(20)]
|
||||
result = _mod._search_queries([], artists, [], limit=5)
|
||||
assert len(result) == 5
|
||||
|
||||
def test_skips_empty(self):
|
||||
similar = [{"name": "", "artist": {"name": ""}}]
|
||||
artists = [{"name": ""}]
|
||||
mb = [{"artist": "", "title": ""}]
|
||||
result = _mod._search_queries(similar, artists, mb)
|
||||
assert result == []
|
||||
|
||||
def test_empty_inputs(self):
|
||||
assert _mod._search_queries([], [], []) == []
|
||||
|
||||
|
||||
class TestCmdSimilar:
|
||||
def test_no_api_key_mb_fallback(self):
|
||||
"""No API key falls back to MusicBrainz for similar results."""
|
||||
def test_no_api_key_mb_list_fallback(self):
|
||||
"""No API key + list mode falls back to MusicBrainz for results."""
|
||||
bot = _FakeBot(api_key="")
|
||||
msg = _Msg(text="!similar Tool")
|
||||
msg = _Msg(text="!similar list Tool")
|
||||
mb_picks = [{"artist": "MB Artist", "title": "MB Song"}]
|
||||
with patch.dict("os.environ", {}, clear=True), \
|
||||
patch("plugins._musicbrainz.mb_search_artist",
|
||||
@@ -390,40 +433,16 @@ class TestCmdSimilar:
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("No similar artists" in r for r in bot.replied)
|
||||
|
||||
def test_no_api_key_play_mode(self):
|
||||
"""No API key + play mode delegates to cmd_play via MB results."""
|
||||
bot = _FakeBot(api_key="")
|
||||
msg = _Msg(text="!similar play Tool")
|
||||
mb_picks = [{"artist": "MB Band", "title": "MB Track"}]
|
||||
play_called = []
|
||||
|
||||
async def fake_play(b, m):
|
||||
play_called.append(m.text)
|
||||
|
||||
music_mod = MagicMock()
|
||||
music_mod.cmd_play = fake_play
|
||||
bot.registry._modules["music"] = music_mod
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True), \
|
||||
patch("plugins._musicbrainz.mb_search_artist",
|
||||
return_value="mbid-123"), \
|
||||
patch("plugins._musicbrainz.mb_artist_tags",
|
||||
return_value=["rock"]), \
|
||||
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||
return_value=mb_picks):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert len(play_called) == 1
|
||||
assert "MB Band" in play_called[0]
|
||||
|
||||
def test_no_artist_nothing_playing(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!similar")
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("Nothing playing" in r for r in bot.replied)
|
||||
|
||||
def test_artist_query_shows_similar(self):
|
||||
def test_list_artist_shows_similar(self):
|
||||
"""!similar list <artist> shows similar artists (display only)."""
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!similar Tool")
|
||||
msg = _Msg(text="!similar list Tool")
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||
with patch.object(_mod, "_get_similar_artists",
|
||||
return_value=SIMILAR_ARTISTS_RESP["similarartists"]["artist"]):
|
||||
@@ -431,26 +450,26 @@ class TestCmdSimilar:
|
||||
assert any("Similar to Tool" in r for r in bot.replied)
|
||||
assert any("Artist B" in r for r in bot.replied)
|
||||
|
||||
def test_track_level_similarity(self):
|
||||
"""When current track has artist + title, tries track similarity first."""
|
||||
def test_list_track_level(self):
|
||||
"""!similar list with track results shows track similarity."""
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {
|
||||
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||
}
|
||||
msg = _Msg(text="!similar")
|
||||
msg = _Msg(text="!similar list")
|
||||
tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=tracks):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("Similar to Tool - Lateralus" in r for r in bot.replied)
|
||||
assert any("Track X" in r for r in bot.replied)
|
||||
|
||||
def test_falls_back_to_artist(self):
|
||||
"""Falls back to artist similarity when no track results."""
|
||||
def test_list_falls_back_to_artist(self):
|
||||
"""!similar list falls back to artist similarity when no track results."""
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {
|
||||
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||
}
|
||||
msg = _Msg(text="!similar")
|
||||
msg = _Msg(text="!similar list")
|
||||
artists = SIMILAR_ARTISTS_RESP["similarartists"]["artist"]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||
with patch.object(_mod, "_get_similar_artists", return_value=artists):
|
||||
@@ -465,71 +484,137 @@ class TestCmdSimilar:
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("No similar artists" in r for r in bot.replied)
|
||||
|
||||
def test_play_mode_artist(self):
|
||||
"""!similar play delegates to music cmd_play."""
|
||||
def test_list_match_score_displayed(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!similar play Tool")
|
||||
artists = [{"name": "Deftones", "match": "0.8"}]
|
||||
play_called = []
|
||||
|
||||
async def fake_play(b, m):
|
||||
play_called.append(m.text)
|
||||
|
||||
music_mod = MagicMock()
|
||||
music_mod.cmd_play = fake_play
|
||||
bot.registry._modules["music"] = music_mod
|
||||
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||
with patch.object(_mod, "_get_similar_artists", return_value=artists):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert len(play_called) == 1
|
||||
assert "Deftones" in play_called[0]
|
||||
|
||||
def test_play_mode_track(self):
|
||||
"""!similar play with track-level results delegates to cmd_play."""
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {
|
||||
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||
}
|
||||
msg = _Msg(text="!similar play")
|
||||
tracks = [{"name": "Schism", "artist": {"name": "Tool"}, "match": "0.9"}]
|
||||
play_called = []
|
||||
|
||||
async def fake_play(b, m):
|
||||
play_called.append(m.text)
|
||||
|
||||
music_mod = MagicMock()
|
||||
music_mod.cmd_play = fake_play
|
||||
bot.registry._modules["music"] = music_mod
|
||||
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=tracks):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert len(play_called) == 1
|
||||
assert "Tool" in play_called[0]
|
||||
assert "Schism" in play_called[0]
|
||||
|
||||
def test_match_score_displayed(self):
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!similar Tool")
|
||||
msg = _Msg(text="!similar list Tool")
|
||||
artists = [{"name": "Deftones", "match": "0.85"}]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||
with patch.object(_mod, "_get_similar_artists", return_value=artists):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("85%" in r for r in bot.replied)
|
||||
|
||||
def test_current_track_no_separator(self):
|
||||
def test_list_current_track_no_separator(self):
|
||||
"""Title without separator uses whole title as search artist."""
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {
|
||||
"current": _FakeTrack(title="Lateralus"),
|
||||
}
|
||||
msg = _Msg(text="!similar")
|
||||
msg = _Msg(text="!similar list")
|
||||
artists = [{"name": "APC", "match": "0.7"}]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||
with patch.object(_mod, "_get_similar_artists", return_value=artists):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("Similar to Lateralus" in r for r in bot.replied)
|
||||
|
||||
def test_builds_playlist(self):
|
||||
"""Default !similar builds playlist and starts playback."""
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!similar Tool")
|
||||
artists = [{"name": "Deftones", "match": "0.8"}]
|
||||
fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")]
|
||||
|
||||
music_mod = MagicMock()
|
||||
music_mod._ps.return_value = {
|
||||
"queue": [], "current": None, "task": None,
|
||||
}
|
||||
music_mod._fade_and_cancel = AsyncMock()
|
||||
music_mod._ensure_loop = MagicMock()
|
||||
bot.registry._modules["music"] = music_mod
|
||||
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||
patch.object(_mod, "_get_similar_artists", return_value=artists), \
|
||||
patch.object(_mod, "_resolve_playlist",
|
||||
return_value=fake_tracks):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
|
||||
music_mod._fade_and_cancel.assert_called_once()
|
||||
music_mod._ensure_loop.assert_called_once()
|
||||
ps = music_mod._ps(bot)
|
||||
assert ps["queue"] == fake_tracks
|
||||
assert any("Playing 1 similar" in r for r in bot.replied)
|
||||
|
||||
def test_builds_playlist_from_current_track(self):
|
||||
"""!similar with no args discovers from currently playing track."""
|
||||
bot = _FakeBot()
|
||||
bot._pstate["music"] = {
|
||||
"current": _FakeTrack(title="Tool - Lateralus"),
|
||||
}
|
||||
msg = _Msg(text="!similar")
|
||||
tracks = SIMILAR_TRACKS_RESP["similartracks"]["track"]
|
||||
fake_tracks = [_FakeTrack(url="http://yt/1", title="Song 1")]
|
||||
|
||||
music_mod = MagicMock()
|
||||
music_mod._ps.return_value = {
|
||||
"queue": [], "current": None, "task": None,
|
||||
}
|
||||
music_mod._fade_and_cancel = AsyncMock()
|
||||
music_mod._ensure_loop = MagicMock()
|
||||
bot.registry._modules["music"] = music_mod
|
||||
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=tracks), \
|
||||
patch.object(_mod, "_resolve_playlist",
|
||||
return_value=fake_tracks):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
|
||||
assert any("Playing 1 similar" in r for r in bot.replied)
|
||||
assert any("Tool" in r for r in bot.replied)
|
||||
|
||||
def test_no_music_mod_falls_back_to_display(self):
|
||||
"""Without music plugin, !similar falls back to display mode."""
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!similar Tool")
|
||||
artists = [{"name": "Deftones", "match": "0.8"}]
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]):
|
||||
with patch.object(_mod, "_get_similar_artists", return_value=artists):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
# Falls back to display since no music module registered
|
||||
assert any("Similar to Tool" in r for r in bot.replied)
|
||||
assert any("Deftones" in r for r in bot.replied)
|
||||
|
||||
def test_no_playable_tracks_resolved(self):
|
||||
"""Shows error when resolution returns empty."""
|
||||
bot = _FakeBot()
|
||||
msg = _Msg(text="!similar Tool")
|
||||
artists = [{"name": "Deftones", "match": "0.8"}]
|
||||
|
||||
music_mod = MagicMock()
|
||||
music_mod._ps.return_value = {"queue": [], "current": None}
|
||||
bot.registry._modules["music"] = music_mod
|
||||
|
||||
with patch.object(_mod, "_get_similar_tracks", return_value=[]), \
|
||||
patch.object(_mod, "_get_similar_artists", return_value=artists), \
|
||||
patch.object(_mod, "_resolve_playlist",
|
||||
return_value=[]):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("No playable tracks" in r for r in bot.replied)
|
||||
|
||||
def test_mb_builds_playlist(self):
|
||||
"""MB fallback results build playlist in play mode."""
|
||||
bot = _FakeBot(api_key="")
|
||||
msg = _Msg(text="!similar Tool")
|
||||
mb_picks = [{"artist": "MB Band", "title": "MB Track"}]
|
||||
fake_tracks = [_FakeTrack(url="http://yt/1", title="MB Track")]
|
||||
|
||||
music_mod = MagicMock()
|
||||
music_mod._ps.return_value = {
|
||||
"queue": [], "current": None, "task": None,
|
||||
}
|
||||
music_mod._fade_and_cancel = AsyncMock()
|
||||
music_mod._ensure_loop = MagicMock()
|
||||
bot.registry._modules["music"] = music_mod
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True), \
|
||||
patch("plugins._musicbrainz.mb_search_artist",
|
||||
return_value="mbid-123"), \
|
||||
patch("plugins._musicbrainz.mb_artist_tags",
|
||||
return_value=["rock"]), \
|
||||
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||
return_value=mb_picks), \
|
||||
patch.object(_mod, "_resolve_playlist",
|
||||
return_value=fake_tracks):
|
||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||
assert any("Playing 1 similar" in r for r in bot.replied)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TestCmdTags
|
||||
|
||||
Reference in New Issue
Block a user