feat: add MusicBrainz fallback to !similar and !tags commands
Remove early return on missing Last.fm API key. Both commands now fall back to MusicBrainz (mb_search_artist -> mb_artist_tags -> mb_find_similar_recordings) when no API key is configured or when Last.fm returns empty results. Same pattern used by discover_similar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -198,9 +198,6 @@ async def cmd_similar(bot, message):
|
|||||||
!similar play <artist> Queue a similar track for named artist
|
!similar play <artist> Queue a similar track for named artist
|
||||||
"""
|
"""
|
||||||
api_key = _get_api_key(bot)
|
api_key = _get_api_key(bot)
|
||||||
if not api_key:
|
|
||||||
await bot.reply(message, "Last.fm API key not configured")
|
|
||||||
return
|
|
||||||
|
|
||||||
parts = message.text.split(None, 2)
|
parts = message.text.split(None, 2)
|
||||||
# !similar play [artist]
|
# !similar play [artist]
|
||||||
@@ -223,38 +220,86 @@ async def cmd_similar(bot, message):
|
|||||||
await bot.reply(message, "Nothing playing and no artist given")
|
await bot.reply(message, "Nothing playing and no artist given")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Try track-level similarity first if we have both artist + title
|
# -- Last.fm path --
|
||||||
similar = []
|
similar = []
|
||||||
if artist and title:
|
similar_artists = []
|
||||||
similar = await loop.run_in_executor(
|
if api_key:
|
||||||
None, _get_similar_tracks, api_key, artist, title,
|
# Try track-level similarity first if we have both artist + title
|
||||||
)
|
if artist and title:
|
||||||
|
similar = await loop.run_in_executor(
|
||||||
|
None, _get_similar_tracks, api_key, artist, title,
|
||||||
|
)
|
||||||
|
# Fall back to artist-level similarity
|
||||||
|
if not similar:
|
||||||
|
search_artist = artist or title
|
||||||
|
similar_artists = await loop.run_in_executor(
|
||||||
|
None, _get_similar_artists, api_key, search_artist,
|
||||||
|
)
|
||||||
|
|
||||||
# Fall back to artist-level similarity
|
# -- MusicBrainz fallback --
|
||||||
if not similar:
|
mb_results = []
|
||||||
|
if not similar and not similar_artists:
|
||||||
search_artist = artist or title
|
search_artist = artist or title
|
||||||
similar_artists = await loop.run_in_executor(
|
try:
|
||||||
None, _get_similar_artists, api_key, search_artist,
|
from plugins._musicbrainz import (
|
||||||
)
|
mb_artist_tags,
|
||||||
if not similar_artists:
|
mb_find_similar_recordings,
|
||||||
await bot.reply(message, f"No similar artists found for '{search_artist}'")
|
mb_search_artist,
|
||||||
|
)
|
||||||
|
mbid = await loop.run_in_executor(
|
||||||
|
None, mb_search_artist, search_artist,
|
||||||
|
)
|
||||||
|
if mbid:
|
||||||
|
tags = await loop.run_in_executor(None, mb_artist_tags, mbid)
|
||||||
|
if tags:
|
||||||
|
mb_results = await loop.run_in_executor(
|
||||||
|
None, mb_find_similar_recordings, search_artist,
|
||||||
|
tags, 20,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.warning("lastfm: MusicBrainz fallback failed", exc_info=True)
|
||||||
|
|
||||||
|
# -- Track-level results (Last.fm) --
|
||||||
|
if similar:
|
||||||
|
if play_mode:
|
||||||
|
pick = random.choice(similar[:10])
|
||||||
|
pick_artist = pick.get("artist", {}).get("name", "")
|
||||||
|
pick_title = pick.get("name", "")
|
||||||
|
search = f"{pick_artist} {pick_title}".strip()
|
||||||
|
if not search:
|
||||||
|
await bot.reply(message, "No playable result found")
|
||||||
|
return
|
||||||
|
message.text = f"!play {search}"
|
||||||
|
music_mod = bot.registry._modules.get("music")
|
||||||
|
if music_mod:
|
||||||
|
await music_mod.cmd_play(bot, message)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
lines = [f"Similar to {artist} - {title}:"]
|
||||||
|
for t in similar[:8]:
|
||||||
|
t_artist = t.get("artist", {}).get("name", "")
|
||||||
|
t_name = t.get("name", "?")
|
||||||
|
match = _fmt_match(t.get("match", ""))
|
||||||
|
suffix = f" ({match})" if match else ""
|
||||||
|
lines.append(f" {t_artist} - {t_name}{suffix}")
|
||||||
|
await bot.long_reply(message, lines, label="similar tracks")
|
||||||
|
return
|
||||||
|
|
||||||
|
# -- Artist-level results (Last.fm) --
|
||||||
|
if similar_artists:
|
||||||
|
search_artist = artist or title
|
||||||
if play_mode:
|
if play_mode:
|
||||||
# Pick a random similar artist and search YouTube
|
|
||||||
pick = random.choice(similar_artists[:10])
|
pick = random.choice(similar_artists[:10])
|
||||||
pick_name = pick.get("name", "")
|
pick_name = pick.get("name", "")
|
||||||
if not pick_name:
|
if not pick_name:
|
||||||
await bot.reply(message, "No playable result found")
|
await bot.reply(message, "No playable result found")
|
||||||
return
|
return
|
||||||
# Inject a !play command with a YouTube search
|
|
||||||
message.text = f"!play {pick_name}"
|
message.text = f"!play {pick_name}"
|
||||||
music_mod = bot.registry._modules.get("music")
|
music_mod = bot.registry._modules.get("music")
|
||||||
if music_mod:
|
if music_mod:
|
||||||
await music_mod.cmd_play(bot, message)
|
await music_mod.cmd_play(bot, message)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Display similar artists
|
|
||||||
lines = [f"Similar to {search_artist}:"]
|
lines = [f"Similar to {search_artist}:"]
|
||||||
for a in similar_artists[:8]:
|
for a in similar_artists[:8]:
|
||||||
name = a.get("name", "?")
|
name = a.get("name", "?")
|
||||||
@@ -264,30 +309,27 @@ async def cmd_similar(bot, message):
|
|||||||
await bot.long_reply(message, lines, label="similar artists")
|
await bot.long_reply(message, lines, label="similar artists")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Track-level results
|
# -- MusicBrainz results --
|
||||||
if play_mode:
|
if mb_results:
|
||||||
pick = random.choice(similar[:10])
|
search_artist = artist or title
|
||||||
pick_artist = pick.get("artist", {}).get("name", "")
|
if play_mode:
|
||||||
pick_title = pick.get("name", "")
|
pick = random.choice(mb_results[:10])
|
||||||
search = f"{pick_artist} {pick_title}".strip()
|
search = f"{pick['artist']} {pick['title']}".strip()
|
||||||
if not search:
|
message.text = f"!play {search}"
|
||||||
await bot.reply(message, "No playable result found")
|
music_mod = bot.registry._modules.get("music")
|
||||||
|
if music_mod:
|
||||||
|
await music_mod.cmd_play(bot, message)
|
||||||
return
|
return
|
||||||
message.text = f"!play {search}"
|
|
||||||
music_mod = bot.registry._modules.get("music")
|
lines = [f"Similar to {search_artist}:"]
|
||||||
if music_mod:
|
for r in mb_results[:8]:
|
||||||
await music_mod.cmd_play(bot, message)
|
lines.append(f" {r['artist']} - {r['title']}")
|
||||||
|
await bot.long_reply(message, lines, label="similar tracks")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Display similar tracks
|
# Nothing found
|
||||||
lines = [f"Similar to {artist} - {title}:"]
|
search_artist = artist or title
|
||||||
for t in similar[:8]:
|
await bot.reply(message, f"No similar artists found for '{search_artist}'")
|
||||||
t_artist = t.get("artist", {}).get("name", "")
|
|
||||||
t_name = t.get("name", "?")
|
|
||||||
match = _fmt_match(t.get("match", ""))
|
|
||||||
suffix = f" ({match})" if match else ""
|
|
||||||
lines.append(f" {t_artist} - {t_name}{suffix}")
|
|
||||||
await bot.long_reply(message, lines, label="similar tracks")
|
|
||||||
|
|
||||||
|
|
||||||
@command("tags", help="Music: !tags [artist] -- show genre tags")
|
@command("tags", help="Music: !tags [artist] -- show genre tags")
|
||||||
@@ -299,9 +341,6 @@ async def cmd_tags(bot, message):
|
|||||||
!tags <artist> Tags for named artist
|
!tags <artist> Tags for named artist
|
||||||
"""
|
"""
|
||||||
api_key = _get_api_key(bot)
|
api_key = _get_api_key(bot)
|
||||||
if not api_key:
|
|
||||||
await bot.reply(message, "Last.fm API key not configured")
|
|
||||||
return
|
|
||||||
|
|
||||||
parts = message.text.split(None, 1)
|
parts = message.text.split(None, 1)
|
||||||
query = parts[1].strip() if len(parts) > 1 else ""
|
query = parts[1].strip() if len(parts) > 1 else ""
|
||||||
@@ -318,9 +357,28 @@ async def cmd_tags(bot, message):
|
|||||||
await bot.reply(message, "Nothing playing and no artist given")
|
await bot.reply(message, "Nothing playing and no artist given")
|
||||||
return
|
return
|
||||||
|
|
||||||
tags = await loop.run_in_executor(
|
# -- Last.fm path --
|
||||||
None, _get_top_tags, api_key, artist,
|
tags = []
|
||||||
)
|
if api_key:
|
||||||
|
tags = await loop.run_in_executor(
|
||||||
|
None, _get_top_tags, api_key, artist,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- MusicBrainz fallback --
|
||||||
|
if not tags:
|
||||||
|
try:
|
||||||
|
from plugins._musicbrainz import mb_artist_tags, mb_search_artist
|
||||||
|
mbid = await loop.run_in_executor(None, mb_search_artist, artist)
|
||||||
|
if mbid:
|
||||||
|
mb_tags = await loop.run_in_executor(
|
||||||
|
None, mb_artist_tags, mbid,
|
||||||
|
)
|
||||||
|
if mb_tags:
|
||||||
|
await bot.reply(message, f"{artist}: {', '.join(mb_tags)}")
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
log.warning("lastfm: MusicBrainz tag fallback failed",
|
||||||
|
exc_info=True)
|
||||||
|
|
||||||
if not tags:
|
if not tags:
|
||||||
await bot.reply(message, f"No tags found for '{artist}'")
|
await bot.reply(message, f"No tags found for '{artist}'")
|
||||||
|
|||||||
@@ -339,12 +339,56 @@ class TestFmtMatch:
|
|||||||
|
|
||||||
|
|
||||||
class TestCmdSimilar:
|
class TestCmdSimilar:
|
||||||
def test_no_api_key(self):
|
def test_no_api_key_mb_fallback(self):
|
||||||
|
"""No API key falls back to MusicBrainz for similar results."""
|
||||||
bot = _FakeBot(api_key="")
|
bot = _FakeBot(api_key="")
|
||||||
msg = _Msg(text="!similar Tool")
|
msg = _Msg(text="!similar Tool")
|
||||||
with patch.dict("os.environ", {}, clear=True):
|
mb_picks = [{"artist": "MB Artist", "title": "MB Song"}]
|
||||||
|
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", "metal"]), \
|
||||||
|
patch("plugins._musicbrainz.mb_find_similar_recordings",
|
||||||
|
return_value=mb_picks):
|
||||||
asyncio.run(_mod.cmd_similar(bot, msg))
|
asyncio.run(_mod.cmd_similar(bot, msg))
|
||||||
assert any("not configured" in r for r in bot.replied)
|
assert any("Similar to Tool" in r for r in bot.replied)
|
||||||
|
assert any("MB Artist" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_no_api_key_mb_no_results(self):
|
||||||
|
"""No API key + MusicBrainz returns nothing shows 'no similar'."""
|
||||||
|
bot = _FakeBot(api_key="")
|
||||||
|
msg = _Msg(text="!similar Tool")
|
||||||
|
with patch.dict("os.environ", {}, clear=True), \
|
||||||
|
patch("plugins._musicbrainz.mb_search_artist",
|
||||||
|
return_value=None):
|
||||||
|
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):
|
def test_no_artist_nothing_playing(self):
|
||||||
bot = _FakeBot()
|
bot = _FakeBot()
|
||||||
@@ -468,12 +512,29 @@ class TestCmdSimilar:
|
|||||||
|
|
||||||
|
|
||||||
class TestCmdTags:
|
class TestCmdTags:
|
||||||
def test_no_api_key(self):
|
def test_no_api_key_mb_fallback(self):
|
||||||
|
"""No API key falls back to MusicBrainz for tags."""
|
||||||
bot = _FakeBot(api_key="")
|
bot = _FakeBot(api_key="")
|
||||||
msg = _Msg(text="!tags Tool")
|
msg = _Msg(text="!tags Tool")
|
||||||
with patch.dict("os.environ", {}, clear=True):
|
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", "progressive metal", "art rock"]):
|
||||||
asyncio.run(_mod.cmd_tags(bot, msg))
|
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||||
assert any("not configured" in r for r in bot.replied)
|
assert any("Tool:" in r for r in bot.replied)
|
||||||
|
assert any("rock" in r for r in bot.replied)
|
||||||
|
assert any("progressive metal" in r for r in bot.replied)
|
||||||
|
|
||||||
|
def test_no_api_key_mb_no_results(self):
|
||||||
|
"""No API key + MusicBrainz returns nothing shows 'no tags'."""
|
||||||
|
bot = _FakeBot(api_key="")
|
||||||
|
msg = _Msg(text="!tags Obscure")
|
||||||
|
with patch.dict("os.environ", {}, clear=True), \
|
||||||
|
patch("plugins._musicbrainz.mb_search_artist",
|
||||||
|
return_value=None):
|
||||||
|
asyncio.run(_mod.cmd_tags(bot, msg))
|
||||||
|
assert any("No tags found" in r for r in bot.replied)
|
||||||
|
|
||||||
def test_no_artist_nothing_playing(self):
|
def test_no_artist_nothing_playing(self):
|
||||||
bot = _FakeBot()
|
bot = _FakeBot()
|
||||||
|
|||||||
Reference in New Issue
Block a user